@@ -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 )
@@ -554,6 +596,192 @@ func (s *GitTokenStore) relativeToRepo(path string) (string, error) {
554596 return rel , nil
555597}
556598
599+ func (s * GitTokenStore ) checkoutConfiguredBranch (repo * git.Repository , worktree * git.Worktree , authMethod transport.AuthMethod ) error {
600+ branchRefName := plumbing .NewBranchReferenceName (s .branch )
601+ headRef , errHead := repo .Head ()
602+ switch {
603+ case errHead == nil && headRef .Name () == branchRefName :
604+ return nil
605+ case errHead != nil && ! errors .Is (errHead , plumbing .ErrReferenceNotFound ):
606+ return fmt .Errorf ("git token store: get head: %w" , errHead )
607+ }
608+
609+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName }); err == nil {
610+ return nil
611+ } else if _ , errRef := repo .Reference (branchRefName , true ); errRef == nil {
612+ return fmt .Errorf ("git token store: checkout branch %s: %w" , s .branch , err )
613+ } else if ! errors .Is (errRef , plumbing .ErrReferenceNotFound ) {
614+ return fmt .Errorf ("git token store: inspect branch %s: %w" , s .branch , errRef )
615+ } else if err := s .checkoutConfiguredRemoteTrackingBranch (repo , worktree , branchRefName , authMethod ); err != nil {
616+ return fmt .Errorf ("git token store: checkout branch %s: %w" , s .branch , err )
617+ }
618+
619+ return nil
620+ }
621+
622+ func (s * GitTokenStore ) checkoutConfiguredRemoteTrackingBranch (repo * git.Repository , worktree * git.Worktree , branchRefName plumbing.ReferenceName , authMethod transport.AuthMethod ) error {
623+ remoteRefName := plumbing .ReferenceName ("refs/remotes/origin/" + s .branch )
624+ remoteRef , err := repo .Reference (remoteRefName , true )
625+ if errors .Is (err , plumbing .ErrReferenceNotFound ) {
626+ if errSync := syncRemoteReferences (repo , authMethod ); errSync != nil {
627+ return fmt .Errorf ("sync remote refs: %w" , errSync )
628+ }
629+ remoteRef , err = repo .Reference (remoteRefName , true )
630+ }
631+ if err != nil {
632+ return err
633+ }
634+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName , Create : true , Hash : remoteRef .Hash ()}); err != nil {
635+ return err
636+ }
637+
638+ cfg , err := repo .Config ()
639+ if err != nil {
640+ return fmt .Errorf ("git token store: repo config: %w" , err )
641+ }
642+ if _ , ok := cfg .Branches [s .branch ]; ! ok {
643+ cfg .Branches [s .branch ] = & config.Branch {Name : s .branch }
644+ }
645+ cfg .Branches [s .branch ].Remote = "origin"
646+ cfg .Branches [s .branch ].Merge = branchRefName
647+ if err := repo .SetConfig (cfg ); err != nil {
648+ return fmt .Errorf ("git token store: set branch config: %w" , err )
649+ }
650+ return nil
651+ }
652+
653+ func syncRemoteReferences (repo * git.Repository , authMethod transport.AuthMethod ) error {
654+ if err := repo .Fetch (& git.FetchOptions {Auth : authMethod , RemoteName : "origin" }); err != nil && ! errors .Is (err , git .NoErrAlreadyUpToDate ) {
655+ return err
656+ }
657+ return nil
658+ }
659+
660+ // resolveRemoteDefaultBranch queries the origin remote to determine the remote's default branch
661+ // (the target of HEAD) and returns the corresponding local branch reference name (e.g. refs/heads/master).
662+ func resolveRemoteDefaultBranch (repo * git.Repository , authMethod transport.AuthMethod ) (resolvedRemoteBranch , error ) {
663+ if err := syncRemoteReferences (repo , authMethod ); err != nil {
664+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: sync remote refs: %w" , err )
665+ }
666+ remote , err := repo .Remote ("origin" )
667+ if err != nil {
668+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: get remote: %w" , err )
669+ }
670+ refs , err := remote .List (& git.ListOptions {Auth : authMethod })
671+ if err != nil {
672+ if resolved , ok := resolveRemoteDefaultBranchFromLocal (repo ); ok {
673+ return resolved , nil
674+ }
675+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: list remote refs: %w" , err )
676+ }
677+ for _ , r := range refs {
678+ if r .Name () == plumbing .HEAD {
679+ if r .Type () == plumbing .SymbolicReference {
680+ if target , ok := normalizeRemoteBranchReference (r .Target ()); ok {
681+ return resolvedRemoteBranch {name : target }, nil
682+ }
683+ }
684+ s := r .String ()
685+ if idx := strings .Index (s , "->" ); idx != - 1 {
686+ if target , ok := normalizeRemoteBranchReference (plumbing .ReferenceName (strings .TrimSpace (s [idx + 2 :]))); ok {
687+ return resolvedRemoteBranch {name : target }, nil
688+ }
689+ }
690+ }
691+ }
692+ if resolved , ok := resolveRemoteDefaultBranchFromLocal (repo ); ok {
693+ return resolved , nil
694+ }
695+ for _ , r := range refs {
696+ if normalized , ok := normalizeRemoteBranchReference (r .Name ()); ok {
697+ return resolvedRemoteBranch {name : normalized , hash : r .Hash ()}, nil
698+ }
699+ }
700+ return resolvedRemoteBranch {}, fmt .Errorf ("resolve remote default: remote default branch not found" )
701+ }
702+
703+ func resolveRemoteDefaultBranchFromLocal (repo * git.Repository ) (resolvedRemoteBranch , bool ) {
704+ ref , err := repo .Reference (plumbing .ReferenceName ("refs/remotes/origin/HEAD" ), true )
705+ if err != nil || ref .Type () != plumbing .SymbolicReference {
706+ return resolvedRemoteBranch {}, false
707+ }
708+ target , ok := normalizeRemoteBranchReference (ref .Target ())
709+ if ! ok {
710+ return resolvedRemoteBranch {}, false
711+ }
712+ return resolvedRemoteBranch {name : target }, true
713+ }
714+
715+ func normalizeRemoteBranchReference (name plumbing.ReferenceName ) (plumbing.ReferenceName , bool ) {
716+ switch {
717+ case strings .HasPrefix (name .String (), "refs/heads/" ):
718+ return name , true
719+ case strings .HasPrefix (name .String (), "refs/remotes/origin/" ):
720+ return plumbing .NewBranchReferenceName (strings .TrimPrefix (name .String (), "refs/remotes/origin/" )), true
721+ default :
722+ return "" , false
723+ }
724+ }
725+
726+ func shouldFallbackToCurrentBranch (repo * git.Repository , err error ) bool {
727+ if ! errors .Is (err , transport .ErrAuthenticationRequired ) && ! errors .Is (err , transport .ErrEmptyRemoteRepository ) {
728+ return false
729+ }
730+ _ , headErr := repo .Head ()
731+ return headErr == nil
732+ }
733+
734+ // checkoutRemoteDefaultBranch ensures the working tree is checked out to the remote's default branch
735+ // (the branch target of origin/HEAD). If the local branch does not exist it will be created to track
736+ // the remote branch.
737+ func checkoutRemoteDefaultBranch (repo * git.Repository , worktree * git.Worktree , authMethod transport.AuthMethod ) error {
738+ resolved , err := resolveRemoteDefaultBranch (repo , authMethod )
739+ if err != nil {
740+ return err
741+ }
742+ branchRefName := resolved .name
743+ // If HEAD already points to the desired branch, nothing to do.
744+ headRef , errHead := repo .Head ()
745+ if errHead == nil && headRef .Name () == branchRefName {
746+ return nil
747+ }
748+ // If local branch exists, attempt a checkout
749+ if _ , err := repo .Reference (branchRefName , true ); err == nil {
750+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName }); err != nil {
751+ return fmt .Errorf ("checkout branch %s: %w" , branchRefName .String (), err )
752+ }
753+ return nil
754+ }
755+ // Try to find the corresponding remote tracking ref (refs/remotes/origin/<name>)
756+ branchShort := strings .TrimPrefix (branchRefName .String (), "refs/heads/" )
757+ remoteRefName := plumbing .ReferenceName ("refs/remotes/origin/" + branchShort )
758+ hash := resolved .hash
759+ if remoteRef , err := repo .Reference (remoteRefName , true ); err == nil {
760+ hash = remoteRef .Hash ()
761+ } else if err != nil && ! errors .Is (err , plumbing .ErrReferenceNotFound ) {
762+ return fmt .Errorf ("checkout remote default: remote ref %s: %w" , remoteRefName .String (), err )
763+ }
764+ if hash == plumbing .ZeroHash {
765+ return fmt .Errorf ("checkout remote default: remote ref %s not found" , remoteRefName .String ())
766+ }
767+ if err := worktree .Checkout (& git.CheckoutOptions {Branch : branchRefName , Create : true , Hash : hash }); err != nil {
768+ return fmt .Errorf ("checkout create branch %s: %w" , branchRefName .String (), err )
769+ }
770+ cfg , err := repo .Config ()
771+ if err != nil {
772+ return fmt .Errorf ("git token store: repo config: %w" , err )
773+ }
774+ if _ , ok := cfg .Branches [branchShort ]; ! ok {
775+ cfg .Branches [branchShort ] = & config.Branch {Name : branchShort }
776+ }
777+ cfg .Branches [branchShort ].Remote = "origin"
778+ cfg .Branches [branchShort ].Merge = branchRefName
779+ if err := repo .SetConfig (cfg ); err != nil {
780+ return fmt .Errorf ("git token store: set branch config: %w" , err )
781+ }
782+ return nil
783+ }
784+
557785func (s * GitTokenStore ) commitAndPushLocked (message string , relPaths ... string ) error {
558786 repoDir := s .repoDirSnapshot ()
559787 if repoDir == "" {
@@ -619,7 +847,16 @@ func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string)
619847 return errRewrite
620848 }
621849 s .maybeRunGC (repo )
622- if err = repo .Push (& git.PushOptions {Auth : s .gitAuth (), Force : true }); err != nil {
850+ pushOpts := & git.PushOptions {Auth : s .gitAuth (), Force : true }
851+ if s .branch != "" {
852+ pushOpts .RefSpecs = []config.RefSpec {config .RefSpec ("refs/heads/" + s .branch + ":refs/heads/" + s .branch )}
853+ } else {
854+ // When branch is unset, pin push to the currently checked-out branch.
855+ if headRef , err := repo .Head (); err == nil {
856+ pushOpts .RefSpecs = []config.RefSpec {config .RefSpec (headRef .Name ().String () + ":" + headRef .Name ().String ())}
857+ }
858+ }
859+ if err = repo .Push (pushOpts ); err != nil {
623860 if errors .Is (err , git .NoErrAlreadyUpToDate ) {
624861 return nil
625862 }
0 commit comments