Skip to content

Commit 000fcb1

Browse files
authored
Merge pull request #2298 from snoyiatk/feat/add-gitstore-branch
feat(gitstore): add support for specifying git branch (via GITSTORE_G…
2 parents ea43361 + 058793c commit 000fcb1

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
@@ -72,6 +72,8 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
7272

7373
CLIProxyAPI Guides: [https://help.router-for.me/](https://help.router-for.me/)
7474

75+
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.
76+
7577
## Management API
7678

7779
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
@@ -72,6 +72,8 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
7272

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

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

7779
请参见 [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
@@ -72,6 +72,8 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
7272

7373
CLIProxyAPIガイド:[https://help.router-for.me/](https://help.router-for.me/)
7474

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

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

cmd/server/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ func main() {
142142
gitStoreRemoteURL string
143143
gitStoreUser string
144144
gitStorePassword string
145+
gitStoreBranch string
145146
gitStoreLocalPath string
146147
gitStoreInst *store.GitTokenStore
147148
gitStoreRoot string
@@ -211,6 +212,9 @@ func main() {
211212
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
212213
gitStoreLocalPath = value
213214
}
215+
if value, ok := lookupEnv("GITSTORE_GIT_BRANCH", "gitstore_git_branch"); ok {
216+
gitStoreBranch = value
217+
}
214218
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
215219
useObjectStore = true
216220
objectStoreEndpoint = value
@@ -345,7 +349,7 @@ func main() {
345349
}
346350
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
347351
authDir := filepath.Join(gitStoreRoot, "auths")
348-
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
352+
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword, gitStoreBranch)
349353
gitStoreInst.SetBaseDir(authDir)
350354
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
351355
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)
@@ -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+
557785
func (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

Comments
 (0)