Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ For detailed setup and usage information:

- **[Getting Started](docs/getting-started.md)** - Repository setup and basic workflows
- **[Commands Reference](docs/commands.md)** - Complete command documentation
- **[Remote Remove Use Cases](docs/remote-remove-use-cases.md)** - Practical scenarios for remote cleanup
- **[Installation Guide](docs/installation.md)** - Platform-specific installation
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
- **[S3 Integration](docs/adding-s3-files.md)** - Adding files via S3 URLs
Expand All @@ -98,6 +99,7 @@ For detailed setup and usage information:
| `git drs remote add` | Add a DRS remote server |
| `git drs remote list` | List configured remotes |
| `git drs remote set` | Set default remote |
| `git drs remote remove`| Remove a configured remote |
| `git drs add-url` | Add files via S3 URLs |
| `git lfs track` | Track file patterns with LFS |
| `git lfs ls-files` | List tracked files |
Expand Down
52 changes: 52 additions & 0 deletions cmd/remote/remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ package remote
import (
"testing"

"github.com/calypr/git-drs/client/indexd"
"github.com/calypr/git-drs/config"
"github.com/calypr/git-drs/internal/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRemoteListArgs(t *testing.T) {
Expand Down Expand Up @@ -44,3 +47,52 @@ func TestRemoteSetArgs(t *testing.T) {
err = SetCmd.Args(SetCmd, []string{"origin", "extra"})
assert.Error(t, err)
}

func TestRemoteRemoveArgs(t *testing.T) {
err := RemoveCmd.Args(RemoveCmd, []string{"origin"})
assert.NoError(t, err)

err = RemoveCmd.Args(RemoveCmd, []string{})
assert.Error(t, err)

err = RemoveCmd.Args(RemoveCmd, []string{"origin", "extra"})
assert.Error(t, err)
}

func TestRemoteRemoveAliases(t *testing.T) {
assert.Contains(t, RemoveCmd.Aliases, "rm")
}

func TestRemoteRemoveRun(t *testing.T) {
tmpDir := testutils.SetupTestGitRepo(t)
testutils.CreateTestConfig(t, tmpDir, &config.Config{
DefaultRemote: config.Remote("origin"),
Remotes: map[config.Remote]config.RemoteSelect{
"origin": {
Gen3: &indexd.Gen3Remote{Endpoint: "https://one.example", ProjectID: "proj-a", Bucket: "bucket-a"},
},
"staging": {
Gen3: &indexd.Gen3Remote{Endpoint: "https://two.example", ProjectID: "proj-b", Bucket: "bucket-b"},
},
},
})

err := RemoveCmd.RunE(RemoveCmd, []string{"origin"})
require.NoError(t, err)

cfg, err := config.LoadConfig()
require.NoError(t, err)

_, hasOrigin := cfg.Remotes[config.Remote("origin")]
assert.False(t, hasOrigin)
assert.Equal(t, config.Remote("staging"), cfg.DefaultRemote)
}

func TestRemoteRemoveRunNotFound(t *testing.T) {
tmpDir := testutils.SetupTestGitRepo(t)
testutils.CreateDefaultTestConfig(t, tmpDir)

err := RemoveCmd.RunE(RemoveCmd, []string{"missing"})
require.Error(t, err)
assert.Contains(t, err.Error(), "remote 'missing' not found")
}
47 changes: 47 additions & 0 deletions cmd/remote/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package remote

import (
"fmt"
"sort"

"github.com/calypr/git-drs/config"
"github.com/spf13/cobra"
)

var RemoveCmd = &cobra.Command{
Use: "remove <remote-name>",
Aliases: []string{"rm"},
Short: "Remove a configured DRS remote",
Long: "Remove a configured DRS remote and update the default remote if needed",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
cmd.SilenceUsage = false
return fmt.Errorf("error: requires exactly 1 argument (remote name), received %d\n\nUsage: %s\n\nRun 'git drs remote list' to see available remotes or 'git drs remote rm --help' for more details", len(args), cmd.UseLine())
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
remoteName := args[0]

cfg, err := config.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

remote := config.Remote(remoteName)
if _, ok := cfg.Remotes[remote]; !ok {
availableRemotes := make([]string, 0, len(cfg.Remotes))
for name := range cfg.Remotes {
availableRemotes = append(availableRemotes, string(name))
}
sort.Strings(availableRemotes)
return fmt.Errorf("remote '%s' not found.\nAvailable remotes: %v", remoteName, availableRemotes)
}

if err := config.RemoveRemote(remote); err != nil {
return fmt.Errorf("failed to remove remote: %w", err)
}

return nil
},
}
1 change: 1 addition & 0 deletions cmd/remote/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ func init() {
Cmd.AddCommand(add.Cmd)
Cmd.AddCommand(ListCmd)
Cmd.AddCommand(SetCmd)
Cmd.AddCommand(RemoveCmd)
}
70 changes: 70 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"log/slog"
"path/filepath"
"sort"
"strings"

"github.com/calypr/data-client/g3client"
Expand Down Expand Up @@ -301,6 +302,75 @@ func GetProjectId(remote Remote) (string, error) {
return rmt.GetProjectId(), nil
}

// RemoveRemote removes a configured remote and updates default-remote when required
func RemoveRemote(name Remote) error {
repo, err := getRepo()
if err != nil {
return err
}

conf, err := repo.Config()
if err != nil {
return err
}

section := conf.Raw.Section(newConfigSection)
legacySection := conf.Raw.Section(legacyConfigSection)
root := section.Subsection(newConfigSubsectionRoot)

remoteSubsectionName := fmt.Sprintf("%s.%s%s", newConfigSubsectionRoot, remoteSubsectionPrefix, name)
legacyRemoteSubsectionName := fmt.Sprintf("%s%s", remoteSubsectionPrefix, name)

hasNamespaced := section.HasSubsection(remoteSubsectionName)
hasLegacy := legacySection.HasSubsection(legacyRemoteSubsectionName)
if !hasNamespaced && !hasLegacy {
return fmt.Errorf("remote '%s' not found", name)
}

if hasNamespaced {
section.RemoveSubsection(remoteSubsectionName)
}
if hasLegacy {
legacySection.RemoveSubsection(legacyRemoteSubsectionName)
}

defaultRemote := root.Option("default-remote")
if defaultRemote == "" {
defaultRemote = legacySection.Option("default-remote")
}

if defaultRemote == string(name) {
remainingSet := make(map[string]struct{})
for _, subsection := range section.Subsections {
if !strings.HasPrefix(subsection.Name, newConfigSubsectionRoot+"."+remoteSubsectionPrefix) {
continue
}
rest := strings.TrimPrefix(subsection.Name, newConfigSubsectionRoot+".")
remainingSet[strings.TrimPrefix(rest, remoteSubsectionPrefix)] = struct{}{}
}
for _, subsection := range legacySection.Subsections {
if !strings.HasPrefix(subsection.Name, remoteSubsectionPrefix) {
continue
}
remainingSet[strings.TrimPrefix(subsection.Name, remoteSubsectionPrefix)] = struct{}{}
}

remaining := make([]string, 0, len(remainingSet))
for remoteName := range remainingSet {
remaining = append(remaining, remoteName)
}
sort.Strings(remaining)

root.RemoveOption("default-remote")
legacySection.RemoveOption("default-remote")
if len(remaining) > 0 {
root.SetOption("default-remote", remaining[0])
}
}

return repo.Storer.SetConfig(conf)
}

// SaveConfig writes the configuration using go-git
func SaveConfig(cfg *Config) error {
repo, err := getRepo()
Expand Down
92 changes: 92 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,95 @@ func TestLoadConfig_NamespacedKeysTakePrecedence(t *testing.T) {
t.Fatalf("expected namespaced gen3 remote loaded, got %#v", newRemote)
}
}

func TestRemoveRemote(t *testing.T) {
setupTestRepo(t)

_, err := UpdateRemote(Remote("origin"), RemoteSelect{Gen3: &indexd.Gen3Remote{Endpoint: "https://origin.example", ProjectID: "origin-proj", Bucket: "origin-bucket"}})
if err != nil {
t.Fatalf("UpdateRemote origin error: %v", err)
}
_, err = UpdateRemote(Remote("staging"), RemoteSelect{Gen3: &indexd.Gen3Remote{Endpoint: "https://staging.example", ProjectID: "staging-proj", Bucket: "staging-bucket"}})
if err != nil {
t.Fatalf("UpdateRemote staging error: %v", err)
}

if err := RemoveRemote(Remote("origin")); err != nil {
t.Fatalf("RemoveRemote error: %v", err)
}

cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}

if _, ok := cfg.Remotes[Remote("origin")]; ok {
t.Fatalf("expected origin to be removed")
}
if cfg.DefaultRemote != Remote("staging") {
t.Fatalf("expected default remote to switch to staging, got %s", cfg.DefaultRemote)
}
}

func TestRemoveRemote_LastRemoteClearsDefault(t *testing.T) {
setupTestRepo(t)

_, err := UpdateRemote(Remote("origin"), RemoteSelect{Gen3: &indexd.Gen3Remote{Endpoint: "https://origin.example", ProjectID: "origin-proj", Bucket: "origin-bucket"}})
if err != nil {
t.Fatalf("UpdateRemote origin error: %v", err)
}

if err := RemoveRemote(Remote("origin")); err != nil {
t.Fatalf("RemoveRemote error: %v", err)
}

cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
if cfg.DefaultRemote != "" {
t.Fatalf("expected default remote to be cleared, got %s", cfg.DefaultRemote)
}
if len(cfg.Remotes) != 0 {
t.Fatalf("expected no remotes, got %d", len(cfg.Remotes))
}
}

func TestRemoveRemote_LegacyRemote(t *testing.T) {
tmpDir := setupTestRepo(t)

commands := [][]string{
{"config", "drs.default-remote", "legacy"},
{"config", "drs.remote.legacy.type", "gen3"},
{"config", "drs.remote.legacy.endpoint", "https://legacy.example"},
{"config", "drs.remote.legacy.project", "legacy-proj"},
{"config", "drs.remote.legacy.bucket", "legacy-bucket"},
{"config", "lfs.customtransfer.drs.remote.new.type", "gen3"},
{"config", "lfs.customtransfer.drs.remote.new.endpoint", "https://new.example"},
{"config", "lfs.customtransfer.drs.remote.new.project", "new-proj"},
{"config", "lfs.customtransfer.drs.remote.new.bucket", "new-bucket"},
}
for _, args := range commands {
cmd := exec.Command("git", args...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v: %s", args, err, string(out))
}
}

if err := RemoveRemote(Remote("legacy")); err != nil {
t.Fatalf("RemoveRemote legacy error: %v", err)
}

cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}

if _, ok := cfg.Remotes[Remote("legacy")]; ok {
t.Fatalf("expected legacy remote to be removed")
}
if cfg.DefaultRemote != Remote("new") {
t.Fatalf("expected default remote to be reassigned to new, got %s", cfg.DefaultRemote)
}
}
Loading