Skip to content

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

Merged
domitriusclark merged 5 commits into
mainfrom
fix/init-rerun-linked-no-git
Nov 4, 2026
Merged

domitriusclark merged 5 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:

  • Linked, no git remote: init prints the project name and URL, netlify deploy as the next step, and how to switch projects (netlify unlink, then netlify init), then exits without prompting.
  • Linked, remote can't be read (unknown --git-remote-name, unparseable URL): same exit, after a warning with the reason. Credentials in the remote URL are redacted.
  • Linked project returns 403 or 404: init warns, sends sites_unlinked, and runs the normal create/link flow, ignoring the old link for this run. .netlify/state.json changes only when a new project is saved, so aborting keeps the old id.
  • Any other error loading the linked project: init stops with the project id and status instead of continuing, so a brief outage can't lead to a duplicate project.
  • Unchanged: a linked project with no repo still goes on to repo setup once a remote exists, a linked project with a repo still prints "This project has been initialized", and prompts for new folders are the same.

Supporting changes: getRepoData errors now carry a reason (no-remote, unknown-remote, invalid-url, unreadable) and it looks for .git before reading the git config. BaseCommand.refreshAccounts is public, so the create flow can load teams after config falls back offline for a missing project.

Tests: npm exec vitest -- run tests/integration/commands/init/init.test.ts: 17 passed, 8 of them new: rerun after a manual create, linked project plus new remote, unreadable remote, 403, 404, 500, and a deleted project with a git remote for both create and connect. The rerun and stale-id tests fail on main. tests/unit/utils/get-repo-data.test.ts: 9 passed, including credential redaction. typecheck, lint, format:check and docs are clean.

Risk: init makes one extra getSite call when config resolved the site offline. If a deleted project's id comes from NETLIFY_SITE_ID rather than state.json, the warning still blames the folder's link, and the next run offers to create another project. Left as is because it's an unlikely setup.

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 →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

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: 61ada043-2e85-4cbb-bc68-9967735e5895
Commits

Reviewing files that changed from the base of the PR and between 6780088 and 2fd9e5b.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent 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.

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

Change: Bug fix

Suggested reviewers: serhalp

Merge Risk: Minimal · previous review 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 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: preventing project recreation when init reruns for a project linked without Git.
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.
Description check Passed The description states the problem (rerunning netlify init in a folder linked to a project without a git remote re-prompts for creation and can create a duplicate project), the approach for each lin…
Full details: Description check

Explanation

The description states the problem (rerunning netlify init in a folder linked to a project without a git remote re-prompts for creation and can create a duplicate project), the approach for each linked-project case, the supporting changes to getRepoData and BaseCommand.refreshAccounts, the tests run, and the risks and rollback. It is specific and directly matches the changeset in init.ts, get-repo-data.ts, base-command.ts, and the init tests, so the check passes.

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.

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

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.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

📊 Benchmark results

Comparing with bab9b28

  • Dependency count: 969 (no change)
  • Package size: 360 MB (no change)
  • Number of ts-expect-error directives: 281 (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: 2fd9e5b

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.

@domitriusclark
domitriusclark merged commit d0bd793 into main Nov 4, 2026
37 checks passed
@domitriusclark
domitriusclark deleted the fix/init-rerun-linked-no-git branch November 4, 2026 18:14
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