Skip to content

Commit 058793c

Browse files
author
Duong M. CUONG
committed
feat(gitstore): honor configured branch and follow live remote default
1 parent 96f5557 commit 058793c

6 files changed

Lines changed: 838 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
6060

6161
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
6262

63+
For the optional git-backed config store, `GITSTORE_GIT_BRANCH` is optional. Leave it empty or unset to follow the remote repository's default branch, and set it only when you want to force a specific branch.
64+
6365
## Management API
6466

6567
see [MANAGEMENT_API.md](https://help.router-for.me/management/api)

README_CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
6060

6161
CLIProxyAPI 用户手册: [https://help.router-for.me/](https://help.router-for.me/cn/)
6262

63+
对于可选的 git 存储配置,`GITSTORE_GIT_BRANCH` 是可选项。留空或不设置时会跟随远程仓库的默认分支,只有在你需要强制指定分支时才设置它。
64+
6365
## 管理 API 文档
6466

6567
请参见 [MANAGEMENT_API_CN.md](https://help.router-for.me/cn/management/api)

README_JA.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
6060

6161
CLIProxyAPIガイド:[https://help.router-for.me/ja/](https://help.router-for.me/ja/)
6262

63+
オプションのgitバックアップ設定ストアでは、`GITSTORE_GIT_BRANCH` は任意です。空のままにするか未設定にすると、リモートリポジトリのデフォルトブランチに従います。特定のブランチを強制したい場合のみ設定してください。
64+
6365
## 管理API
6466

6567
[MANAGEMENT_API.md](https://help.router-for.me/ja/management/api)を参照

cmd/server/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func main() {
140140
gitStoreRemoteURL string
141141
gitStoreUser string
142142
gitStorePassword string
143+
gitStoreBranch string
143144
gitStoreLocalPath string
144145
gitStoreInst *store.GitTokenStore
145146
gitStoreRoot string
@@ -209,6 +210,9 @@ func main() {
209210
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
210211
gitStoreLocalPath = value
211212
}
213+
if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
214+
gitStoreBranch = value
215+
}
212216
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
213217
useObjectStore = true
214218
objectStoreEndpoint = value
@@ -343,7 +347,7 @@ func main() {
343347
}
344348
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
345349
authDir := filepath.Join(gitStoreRoot, "auths")
346-
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
350+
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
347351
gitStoreInst.SetBaseDir(authDir)
348352
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
349353
log.Errorf("failed to prepare git token store: %v", errRepo)

internal/store/gitstore.go

Lines changed: 242 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
556784
func (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

Comments
 (0)