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
14 changes: 8 additions & 6 deletions src/commands/apps/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {Command, flags} from '@heroku-cli/command'
import * as color from '@heroku/heroku-cli-util/color'
import {Args, ux} from '@oclif/core'

import * as git from '../../lib/ci/git.js'
import {gitService} from '../../lib/ci/git.js'
import ConfirmCommand from '../../lib/confirm-command.js'

export default class Destroy extends Command {
Expand Down Expand Up @@ -35,13 +35,15 @@ export default class Destroy extends Command {
* you want, and they can all point to the same url.
* The only requirement is that the "name" is unique.
*/
if (git.inGitRepo()) {
if (gitService.inGitRepo()) {
// delete git remotes pointing to this app
const remotes = await git.listRemotes()
await Promise.all([
remotes.get(git.gitUrl(app))?.map(({name}) => git.rmRemote(name)),
remotes.get(git.sshGitUrl(app))?.map(({name}) => git.rmRemote(name)),
const remotes = await gitService.listRemotes()
// Deduplicate remote names (same name appears for fetch and push)
const names = new Set([
...(remotes.get(gitService.gitUrl(app))?.map(({name}) => name) ?? []),
...(remotes.get(gitService.sshGitUrl(app))?.map(({name}) => name) ?? []),
])
await Promise.all([...names].map(name => gitService.rmRemote(name)))
}

ux.action.stop()
Expand Down
21 changes: 20 additions & 1 deletion src/lib/ci/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ async function createRemote(remote: string, url: string) {
return null
}

// GitService class for easier testing/stubbing
export class GitService {
async createArchive(ref: string) {
return createArchive(ref)
Expand All @@ -172,9 +171,29 @@ export class GitService {
return githubRepository()
}

gitUrl(app?: string) {
return gitUrl(app)
}

inGitRepo() {
return inGitRepo()
}

async listRemotes() {
return listRemotes()
}

async readCommit(commit: string) {
return readCommit(commit)
}

async rmRemote(remote: string) {
return rmRemote(remote)
}

sshGitUrl(app: string) {
return sshGitUrl(app)
}
}

// Export a shared instance for use across commands
Expand Down
71 changes: 71 additions & 0 deletions test/unit/commands/apps/destroy.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {runCommand} from '@heroku-cli/test-utils'
import {expect} from 'chai'
import nock from 'nock'
import {createSandbox} from 'sinon'

import Destroy from '../../../../src/commands/apps/destroy.js'
import {gitService} from '../../../../src/lib/ci/git.js'

describe('apps:destroy', function () {
let api: nock.Scope
Expand Down Expand Up @@ -43,4 +45,73 @@ describe('apps:destroy', function () {

expect(error?.message).to.include('No app specified.')
})

describe('git remote cleanup', function () {
const sandbox = createSandbox()

afterEach(function () {
sandbox.restore()
})

it('removes duplicate git remotes without error (issue #3677)', async function () {
api
.get('/apps/myapp').reply(200, {name: 'myapp'})
.delete('/apps/myapp').reply(200)

const rmRemoteCalls: string[] = []

// Stub gitService methods
sandbox.stub(gitService, 'inGitRepo').returns(true)
// Return a map with duplicate entries (fetch + push for same remote)
const mockRemotes = new Map([
['https://git.heroku.com/myapp.git', [
{kind: '(fetch)', name: 'heroku'},
{kind: '(push)', name: 'heroku'},
]],
])
sandbox.stub(gitService, 'listRemotes').resolves(mockRemotes)
sandbox.stub(gitService, 'gitUrl').returns('https://git.heroku.com/myapp.git')
sandbox.stub(gitService, 'sshGitUrl').returns('git@git.heroku.com:myapp.git')
sandbox.stub(gitService, 'rmRemote').callsFake(async (name: string) => {
rmRemoteCalls.push(name)
})

await runCommand(Destroy, ['--app', 'myapp', '--confirm', 'myapp'])

// Verify rmRemote was called exactly once (deduplication worked)
expect(rmRemoteCalls.length).to.equal(1)
expect(rmRemoteCalls[0]).to.equal('heroku')
})

it('removes multiple different remotes', async function () {
api
.get('/apps/myapp').reply(200, {name: 'myapp'})
.delete('/apps/myapp').reply(200)

const rmRemoteCalls: string[] = []

sandbox.stub(gitService, 'inGitRepo').returns(true)
// Multiple remotes with duplicates (fetch + push for each)
const mockRemotes = new Map([
['https://git.heroku.com/myapp.git', [
{kind: '(fetch)', name: 'heroku'},
{kind: '(push)', name: 'heroku'},
{kind: '(fetch)', name: 'production'},
{kind: '(push)', name: 'production'},
]],
])
sandbox.stub(gitService, 'listRemotes').resolves(mockRemotes)
sandbox.stub(gitService, 'gitUrl').returns('https://git.heroku.com/myapp.git')
sandbox.stub(gitService, 'sshGitUrl').returns('git@git.heroku.com:myapp.git')
sandbox.stub(gitService, 'rmRemote').callsFake(async (name: string) => {
rmRemoteCalls.push(name)
})

await runCommand(Destroy, ['--app', 'myapp', '--confirm', 'myapp'])

// Verify both remotes were removed exactly once each
expect(rmRemoteCalls.length).to.equal(2)
expect(rmRemoteCalls).to.have.members(['heroku', 'production'])
})
})
})
Loading