Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,11 @@ SSH cloning on Windows requires your SSH key to be accessible from WSL2 or Docke

``gitbackup`` requires a [GitHub API access token](https://github.com/blog/1509-personal-api-tokens) for
backing up GitHub repositories, a [GitLab personal access token](https://gitlab.com/-/user_settings/personal_access_tokens)
for GitLab repositories, a username and [API token](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) (or [app password](https://bitbucket.org/account/settings/app-passwords/)) for
for GitLab repositories, an Atlassian account email plus an [API token](https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/) (or [app password](https://bitbucket.org/account/settings/app-passwords/)) for
Bitbucket repositories, or a [Forgejo access token][https://docs.codeberg.org/advanced/access-token/] for Forgejo.

You can supply the tokens to ``gitbackup`` using ``GITHUB_TOKEN``, ``GITLAB_TOKEN``, or ``FORGEJO_TOKEN`` environment
variables respectively, and the Bitbucket credentials with ``BITBUCKET_USERNAME`` and either ``BITBUCKET_TOKEN`` or ``BITBUCKET_PASSWORD``.
variables respectively, and the Bitbucket credentials with ``BITBUCKET_EMAIL`` (or ``BITBUCKET_USERNAME`` for legacy app-password setups) and either ``BITBUCKET_TOKEN`` or ``BITBUCKET_PASSWORD``. Bitbucket additionally requires ``BITBUCKET_WORKSPACES`` (comma-separated workspace slugs) because Atlassian removed the cross-workspace listing APIs on April 14, 2026.

### GitHub Specific oAuth App Flow

Expand Down Expand Up @@ -435,18 +435,22 @@ $ GITLAB_TOKEN=secret$token gitbackup -service gitlab -githost.url https://git.y

#### Backing up your Bitbucket repositories

Atlassian removed the cross-workspace listing endpoints on April 14, 2026.
There is no supported way to enumerate the workspaces a user belongs to programmatically, so you must list them
explicitly via ``BITBUCKET_WORKSPACES``.

To backup all your Bitbucket repositories to the default backup directory (``$HOME/.gitbackup/``):

Using an API token (recommended):

```lang=bash
$ BITBUCKET_USERNAME=<your atlassian email> BITBUCKET_TOKEN=token gitbackup -service bitbucket
$ BITBUCKET_EMAIL=<your atlassian email> BITBUCKET_TOKEN=token BITBUCKET_WORKSPACES=ws1,ws2 gitbackup -service bitbucket
```

Using an app password (deprecated, disabled after June 9, 2026):

```lang=bash
$ BITBUCKET_USERNAME=username BITBUCKET_PASSWORD=password gitbackup -service bitbucket
$ BITBUCKET_USERNAME=username BITBUCKET_PASSWORD=password BITBUCKET_WORKSPACES=ws1,ws2 gitbackup -service bitbucket
```

#### Backing up your Forgejo repositories
Expand Down
25 changes: 19 additions & 6 deletions bitbucket.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"log"
"os"
"strings"

bitbucket "github.com/ktrysmt/go-bitbucket"
Expand All @@ -11,15 +13,26 @@ func getBitbucketRepositories(
ignoreFork bool,
) ([]*Repository, error) {

// As of April 14, 2026 Atlassian removed the cross-workspace listing
// endpoints (/2.0/workspaces and /2.0/user/permissions/workspaces) under
// changelog entries CHANGE-2770 / CHANGE-3022. There is no supported way
// to enumerate the workspaces a user belongs to programmatically. The
// caller must supply the workspace slugs via BITBUCKET_WORKSPACES
// (comma-separated).
workspacesEnv := os.Getenv("BITBUCKET_WORKSPACES")
if workspacesEnv == "" {
log.Fatal("BITBUCKET_WORKSPACES environment variable not set (comma-separated workspace slugs)")
}

var repositories []*Repository

resp, err := client.Workspaces.List()
if err != nil {
return nil, err
}
for _, slug := range strings.Split(workspacesEnv, ",") {
slug = strings.TrimSpace(slug)
if slug == "" {
continue
}

for _, workspace := range resp.Workspaces {
options := &bitbucket.RepositoriesOptions{Owner: workspace.Slug}
options := &bitbucket.RepositoriesOptions{Owner: slug}

resp, err := client.Repositories.ListForAccount(options)
if err != nil {
Expand Down
15 changes: 11 additions & 4 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,16 @@ func newGitLabClient(gitHostURLParsed *url.URL) *gitlab.Client {

// newBitbucketClient creates a new Bitbucket client
func newBitbucketClient(gitHostURLParsed *url.URL) *bitbucket.Client {
bitbucketUsername := os.Getenv("BITBUCKET_USERNAME")
if bitbucketUsername == "" {
log.Fatal("BITBUCKET_USERNAME environment variable not set")
// Atlassian API tokens are scoped to the Atlassian account, which is
// identified by an email address rather than a Bitbucket username.
// Prefer BITBUCKET_EMAIL for clarity and fall back to BITBUCKET_USERNAME
// for backwards compatibility with legacy app-password setups.
bitbucketEmailOrUsername := os.Getenv("BITBUCKET_EMAIL")
if bitbucketEmailOrUsername == "" {
bitbucketEmailOrUsername = os.Getenv("BITBUCKET_USERNAME")
}
if bitbucketEmailOrUsername == "" {
log.Fatal("BITBUCKET_EMAIL or BITBUCKET_USERNAME environment variable not set")
}

bitbucketPasswordOrToken := os.Getenv("BITBUCKET_TOKEN")
Expand All @@ -185,7 +192,7 @@ func newBitbucketClient(gitHostURLParsed *url.URL) *bitbucket.Client {
}

gitHostToken = bitbucketPasswordOrToken
client, err := bitbucket.NewBasicAuth(bitbucketUsername, bitbucketPasswordOrToken)
client, err := bitbucket.NewBasicAuth(bitbucketEmailOrUsername, bitbucketPasswordOrToken)
if err != nil {
log.Fatalf("Error creating Bitbucket client: %v", err)
}
Expand Down
16 changes: 10 additions & 6 deletions repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func setupRepositoryTests() {
os.Setenv("GITLAB_TOKEN", "$$$randome")
os.Setenv("BITBUCKET_USERNAME", "bbuser")
os.Setenv("BITBUCKET_PASSWORD", "$$$randomp")
os.Setenv("BITBUCKET_WORKSPACES", "ws1,ws2")
os.Setenv("FORGEJO_TOKEN", "$$$randome")
// test server
mux = http.NewServeMux()
Expand Down Expand Up @@ -237,20 +238,23 @@ func TestGetBitbucketRepositories(t *testing.T) {
setupRepositoryTests()
defer teardownRepositoryTests()

mux.HandleFunc("/workspaces", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"slug": "abc"}]}`)
mux.HandleFunc("/repositories/ws1", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"ws1/repo1", "slug":"repo1", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/ws1/repo1.git"}, {"name":"ssh", "href":"git@bitbucket.org:ws1/repo1.git"}]}}]}`)
})

mux.HandleFunc("/repositories/abc", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"abc/def", "slug":"def", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/abc/def.git"}, {"name":"ssh", "href":"git@bitbucket.org:abc/def.git"}]}}]}`)
mux.HandleFunc("/repositories/ws2", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"pagelen": 10, "page": 1, "size": 1, "values": [{"full_name":"ws2/repo2", "slug":"repo2", "is_private":true, "links":{"clone":[{"name":"https", "href":"https://bbuser@bitbucket.org/ws2/repo2.git"}, {"name":"ssh", "href":"git@bitbucket.org:ws2/repo2.git"}]}}]}`)
})

repos, err := getRepositories(BitbucketClient, "bitbucket", "", []string{}, "", "", false, "")
if err != nil {
t.Fatalf("%v", err)
}
var expected []*Repository
expected = append(expected, &Repository{Namespace: "abc", CloneURL: "git@bitbucket.org:abc/def.git", Name: "def", Private: true})
expected = append(
expected,
&Repository{Namespace: "ws1", CloneURL: "git@bitbucket.org:ws1/repo1.git", Name: "repo1", Private: true},
&Repository{Namespace: "ws2", CloneURL: "git@bitbucket.org:ws2/repo2.git", Name: "repo2", Private: true},
)
if !reflect.DeepEqual(repos, expected) {
for i := 0; i < len(repos); i++ {
t.Errorf("Expected %+v, Got %+v", expected[i], repos[i])
Expand Down
Loading