Skip to content

fix(init): don't recreate projects linked without git - #8646

Open
domitriusclark wants to merge 4 commits into
mainfrom
fix/init-rerun-linked-no-git
Open

domitriusclark wants to merge 4 commits into
mainfrom
fix/init-rerun-linked-no-git

Conversation

@domitriusclark

@domitriusclark domitriusclark commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Problem: Rerunning netlify init in a folder linked to a project with no git remote asks "create a Netlify project without a git repository?" again, and answering yes creates a second project and relinks the folder. Agents rerun init often, and projects started from netlify.ai usually have no git, so this hits the main setup path.

Approach: In init.ts, a folder linked with no git remote now prints the project name, URL and netlify deploy as the next step, then exits. A linked project with no repo still goes on to repo setup once a remote exists. If the linked project returns 404, init warns and runs the normal create/link flow. It reloads teams first, because config falls back offline for a deleted project and leaves the team list empty. That needed BaseCommand.refreshAccounts made public. Prompts for new folders are unchanged.

Tests: three new integration tests in tests/integration/commands/init/init.test.ts: rerun after a manual create (one POST to <account>/sites, no second no-git prompt), linked project plus new remote (reaches repo PATCH), stale project id (warning, one create). npm exec vitest -- run tests/integration/commands/init/init.test.ts: 12 passed. The rerun and stale-id tests fail on main. typecheck, lint, format:check and docs are clean, with no doc changes.

Risk: init now makes one extra getSite call when config resolved the site without details (offline fallback). Merges cleanly with #8638; the merged tree passes all 13 init tests.

Rollback: revert this PR.


  • Update or add tests
  • Make sure the status checks below are successful

Rerunning init in a folder linked to a site with no repo and no git
remote created a second site. Treat it as done, and ignore a linked
site that returns 404 so the normal create/link flow runs.
@domitriusclark
domitriusclark requested a review from a team as a code owner October 21, 2026 07:26
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c305990d-beb9-412f-ba59-d6c064a38da7
Commits

Reviewing files that changed from the base of the PR and between 3a23cde and 6780088.

Files selected for processing: 5
  • eslint-suppressions.json
  • src/commands/init/init.ts
  • src/utils/get-repo-data.ts
  • tests/integration/commands/init/init.test.ts
  • tests/unit/utils/get-repo-data.test.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

📝 Summary

Summary by CodeRabbit

  • New Features

    • Initialization now checks linked project details before inspecting Git remotes, reusing or configuring an existing project when possible.
    • If a linked project has no usable repository URL and no Git remote is found, initialization reports the linked project and exits.
  • Bug Fixes

    • Handles inaccessible or deleted linked projects by clearing the stale link and continuing when possible; other lookup errors are reported.
    • Git repository errors now distinguish missing or unknown remotes, invalid URLs, and unreadable data. Invalid-URL errors redact credentials.

The init command now resolves linked-site information before checking repository data. It handles inaccessible or missing linked sites, and reports linked-project instructions when Git metadata is unavailable. Repository lookup returns structured errors for missing or unknown remotes, invalid URLs, and unreadable data. Invalid-URL messages redact credentials. Tests cover linked-project reruns, stale links, Git-backed initialization, and API errors.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: serhalp

Merge Risk: Minimal · up to 67800

The change is ready to merge after normal checks; no actionable issue remains.

Security architecture risk: Low · up to fc0c9

The change reduces unintended project creation without demonstrating new privilege exposure. One recovery path remains inconsistent: choosing an existing project after a stale link is detected can retain the deleted project identity instead of establishing a replacement link.

Retained concerns

  • Low · reliability · inferred: The new stale-link recovery invalidates only init's local linkedSiteInfo value, leaving command.netlify.siteInfo populated. With a Git remote, choosing to connect an existing project invokes link, which treats the stale object as an existing link and returns it without project selection. Init then persists and configures that deleted identity. The newly offered recovery transition therefore does not establish consistent project identity across components.
Security review details

Security Blast Radius

  • inferred — The changed flow affects the current working directory's project binding and project operations available through the current API client. The inspected change introduces no new credential source or broader account authority; remote authorization remains dependent on the service.

Trust Boundaries and Controls

  • observed — Init authenticates before resolving linked metadata. The newly reachable account refresh calls listAccountsForUser without accepting a caller-supplied identity. Project creation selects a team from the available account collection and submits that team's slug through the API.

Resilience and Maintainability Implications

  • observed — The recovery caller refreshes accounts only when the collection is empty. Refresh failure leaves it empty, and team resolution rejects an empty collection before project creation. This bounds the refresh-failure path without granting fallback authority.
Pre-merge checks: Passed 5
Check name Status Explanation
Description check Passed The description clearly explains the init behavior change, linked projects without Git remotes, stale project handling, tests, risk, and rollback.
Title check Passed The title clearly identifies the main change: preventing project recreation when init reruns for a project linked without Git.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5 files. (1 skipped: 1 unsupported.)

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Linked repositories: Some configured repositories could not be included in this review. Cross-repository analysis may be incomplete. Please check linked-repository access and settings.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

📊 Benchmark results

Comparing with f1632d5

  • Dependency count: 968 (no change)
  • Package size: 337 MB (no change)
  • Number of ts-expect-error directives: 280 (no change)

@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/netlify-cli@8646

commit: 6780088

Comment thread src/commands/init/init.ts Outdated
return (await api.getSite({ siteId: siteInfo.id })) as unknown as SiteInfo
} catch (error_) {
if ((error_ as APIError).status === 404) {
warn(`The Netlify project ${siteInfo.id} linked to this folder no longer exists. Continuing without it.`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] After this warning, command.netlify.siteInfo and the linked siteId still point at the deleted project, so the rest of init doesn't actually continue without it.

With a git remote, picking "Connect this directory to an existing Netlify project" lands in link(), which sees a non-empty siteInfo, prints Project already linked to "undefined" and hands back the deleted { id }. init saves that id again and runs configureRepo against it. Creating a new project gets through, but the link() call inside sitesCreate prints the same already linked to "undefined" line. The stale-id test hits that too, it just doesn't check for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a23cde. On a 403 or 404, init now removes siteId from .netlify/state.json, clears command.siteId and resets siteInfo to {}, so link() and sitesCreate see an unlinked folder. The stale-id tests now assert no "already linked" line and that the saved id is the new project's. A new test covers a git remote plus create-new; it PATCHes only the new site.

The connect-existing test can't drive link()'s prompt without a TTY, so it stops at the non-interactive link error. It still shows there is no "already linked" line and that siteId is cleared. On the previous commit it hangs.

On a 403 this deletes siteId from a state.json a teammate may have committed. Creating or linking a project overwrites that value anyway.

Comment thread src/commands/init/init.ts Outdated
}
return undefined
}
throw error_

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] Anything other than a 404 here now ends init with the raw API error, where before it carried on with the bare { id }. Intended? It's not in the description and no test reaches it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended, and changed in c1211a7. A 403 now continues like a 404, with its own warning. Any other error stops init with the project id and status rather than continuing, so a transient 5xx can't lead to a duplicate create. The 500 test checks the error and that nothing is created.

Comment thread src/commands/init/init.ts
// Look for local repo
const repoData = await getRepoData({ workingDir: command.workingDir, remoteName: options.gitRemoteName })
if ('error' in repoData) {
if (linkedSiteInfo) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] Every getRepoData error ends up here, not only a missing remote. A --git-remote-name that doesn't exist, or a remote URL that won't parse, gets "already linked, run deploy" with nothing about the remote.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a23cde. If the remote can't be read, a linked folder now prints the remote error and how to fix it before the "already linked" message. A new test uses --git-remote-name upstream.

I kept this exit rather than main's path, because on main these errors reach the "create without git" prompt, which is how the duplicate happens. This also fixed a no-git folder reporting "no gitconfig to be found": getRepoData now looks for .git before reading the git config.

t.expect(second.stdout).not.toContain(NO_GIT_PROMPT)
t.expect(second.stdout).toContain('This folder is already linked to the Netlify project test-site-name')
t.expect(second.stdout).toContain('https://test-site-name.netlify.app/')
t.expect(second.stdout).toContain('deploy')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Almost any output contains deploy. customizeExitMessage on the new exit path isn't covered either.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3a23cde. The rerun test now asserts the full deploy line and the unlink hint.

I removed customizeExitMessage from this exit rather than test it. Its only caller, netlify dev, calls init only when no project is linked, so it can't reach this path. I'll restore it with a test if you'd rather keep it.

A 403 now continues like a 404. Other errors stop init with the project
id and status instead of risking a duplicate create on a transient
failure. The linked-folder message also says how to switch projects.
A 403 or 404 linked project is now removed from state and memory, so
link and create treat the folder as unlinked. A linked folder whose git
remote can't be read reports the remote error instead of only deploy.

@seancdavis seancdavis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments for consideration. Take a look and I'll approve if you don't want to adjust anything.

Comment thread src/commands/init/init.ts Outdated

const logLinkedWithoutGitAndExit = ({ siteInfo, repoError }: { siteInfo: SiteInfo; repoError: string }): never => {
if (repoError !== NO_GIT_REMOTE_ERROR) {
warn(`Could not read the git remote: ${repoError}`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] When the remote URL doesn't parse, repoError carries the whole URL from .git/config, so any credentials in it land in the terminal or a CI log. Main never printed this error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6780088. getRepoData now strips anything before @ in the URL, so the error shows https://***@.... The fix is in getRepoData itself, so any caller that prints the error gets it too. A unit test covers a URL with a password.

Comment thread src/commands/init/init.ts Outdated
)
}
warn(unusableLinkWarning)
command.netlify.state.delete('siteId')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] On a 403 this removes the saved link before anything's been asked, so Ctrl+C at the next prompt leaves a teammate's committed siteId gone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6780088. init no longer deletes siteId from state.json. It ignores the linked project for this run only, and the file changes only when a new project is linked or created. Ctrl+C now leaves the teammate's siteId in place. The connect-existing test now checks that an aborted run keeps the old id.

Comment thread src/commands/init/init.ts Outdated
}
warn(unusableLinkWarning)
command.netlify.state.delete('siteId')
command.siteId = undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] The unlink command sends sites_unlinked when it drops siteId. This path doesn't, so these unlinks won't show up there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6780088. init now sends sites_unlinked with kind: 'init' and reason: 'forbidden' or 'not_found' when it stops using the link. It's sent when init drops the link for the run, so an aborted run still counts, even though state.json keeps the id.


test('netlify init ignores a linked project that no longer exists', async (t) => {
await withSiteBuilder(t, async (builder) => {
await builder.withStateFile({ siteId: 'deleted_site_id' }).build()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] These 404s come from the mock API's fallback for an unknown route, not a declared response like the 403 and 500 tests have.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6780088. The deleted-project tests now declare sites/deleted_site_id as a 404 route, the same way the 403 and 500 tests do.

Comment thread src/commands/init/init.ts Outdated
}

const logLinkedWithoutGitAndExit = ({ siteInfo, repoError }: { siteInfo: SiteInfo; repoError: string }): never => {
if (repoError !== NO_GIT_REMOTE_ERROR) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This tells "no remote" apart from the other errors by matching the message text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6780088. getRepoData errors now carry a reason (no-remote, unknown-remote, invalid-url or unreadable), and init checks reason !== 'no-remote' instead of the message.

An unusable linked project is now ignored for the run instead of
deleted from state.json, so aborting keeps it, and init reports
sites_unlinked. getRepoData returns a reason code and hides URL
credentials in its error.

@seancdavis seancdavis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description still only covers the first commit. Worth a refresh before merge.

Comment thread src/commands/init/init.ts
}
warn(unusableLink.warning)
await track('sites_unlinked', { siteId: siteInfo.id, kind: 'init', reason: unusableLink.reason })
ignoreLinkedSiteForThisRun(command)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[consider] When the dead id comes from NETLIFY_SITE_ID, this still says the folder's link is the problem, and picking create saves the new project over whatever state.json had.

LocalState.get('siteId') returns the env var ahead of state.json, so the next run resolves the dead id again and offers to create another one.

Comment thread src/commands/init/init.ts
`${netlifyCommand()} unlink`,
)}, then ${chalk.cyanBright.bold(`${netlifyCommand()} init`)}.`,
)
return exit()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This exits 0 even when the remote couldn't be read, so an agent running init --git-remote-name upstream sees success and moves on without the repo connected.

Comment thread src/commands/init/init.ts
return exit()
}

const ignoreLinkedSiteForThisRun = (command: BaseCommand): void => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This rebuilds BaseCommand's site object by hand, so anything added to site there later won't show up here. The id setter isn't called anywhere on these paths either.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants