feat: fork-aware update flow#455
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds fork-aware behavior to the PortOS self-update flow by detecting whether the running checkout is tracking upstream atomantic/PortOS or a GitHub fork, then updating both the UI and server-side update gating to avoid confusing “Update” no-ops for fork users.
Changes:
- Introduces
server/lib/gitRemote.jsto parse/classify the localoriginremote and centralize upstream constants. - Adds
/api/update/sync-fork(wrapsgh repo sync) and gates/api/update/executefor forks unless recently synced or explicitly acknowledged. - Updates the Update tab to show origin/fork status and provide fork-specific update actions; update scripts now log the active origin URL.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
update.sh |
Logs the active origin URL before pulling so update logs show the actual source repo. |
update.ps1 |
Windows equivalent: logs origin URL before pulling. |
server/lib/gitRemote.js |
New helper to read/parse origin URL, classify upstream vs fork, and expose upstream constants. |
server/lib/gitRemote.test.js |
Unit tests for URL parsing and origin classification. |
server/lib/index.js |
Exposes gitRemote.js via the server lib barrel export. |
server/lib/README.md |
Documents the new gitRemote.js helper in the lib catalog. |
server/services/updateChecker.js |
Adds remoteInfo + upstream to status, adds syncFork(), persists lastForkSync, and uses upstream constants for release polling. |
server/services/updateChecker.test.js |
Tests for getRemoteInfo, remoteInfo in status, and syncFork behavior/state persistence. |
server/routes/update.js |
Adds /sync-fork endpoint and forks-only precondition logic to /execute. |
client/src/services/apiSystem.js |
Adds syncPortosFork and extends executePortosUpdate to accept options. |
client/src/components/apps/tabs/UpdateTab.jsx |
Adds fork banner + three fork-specific update buttons and wiring for fork sync/update flows. |
CLAUDE.md |
Documents the fork-aware update architecture and invariants for future contributors. |
.changelog/NEXT.md |
Changelog entry describing the fork-aware update flow. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Detect whether the local clone's origin remote is the upstream atomantic/PortOS
or a personal fork via a new server/lib/gitRemote.js helper (parses SCP/HTTPS/
ssh:// remote URLs, case-insensitive owner/repo match). Update tab now shows
the origin classification and, on forks, swaps "Update Now" for three explicit
buttons:
- Sync Fork & Update — `gh repo sync <fork> --source atomantic/PortOS`
then proceed (fast-forward only, refuses to clobber
divergent fork commits)
- Sync Fork Only — sync without applying
- Update from Fork As-Is — skip sync, pull from your fork's origin
POST /api/update/sync-fork wraps `gh repo sync` and returns 409 FORK_DIVERGED
when the fork's main has commits not on upstream — error message points the
user at PRs, feature branches, or the explicit --force escape they can run
from a terminal. Never adds --force server-side.
POST /api/update/execute gates fork runs behind either a fresh lastForkSync
(<=10 min, same fullName) or acknowledgeFork: true to avoid the silent
"clicked Update, nothing happened" failure mode when a fork's main is behind
upstream.
Release-check still polls atomantic/PortOS so fork users continue to see
upstream version notifications. update.sh / update.ps1 now log the active
origin URL so users can confirm in logs which repo they pulled from.
Tests: 17 cases for gitRemote URL parsing + classification; 12 new cases in
updateChecker.test.js covering remoteInfo, syncFork happy paths, error
classifications, and persisted lastForkSync metadata.
- routes/update.js: replace stale '422-shaped' comment with the actual status-code matrix (400/409/502) the route returns; sharpen the 409 FORK_DIVERGED message to make clear the overwrite would land on the remote fork's main on GitHub, not on local commits. - services/updateChecker.js: fix syncFork JSDoc — it throws plain Error for pre-flight refusals (route translates to ServerError + status), not ServerError instances. - lib/gitRemote.js: docstring updates to reflect host-agnostic parsing; tighten SCP + URL regexes to require exactly owner+repo path segments (was permissive, would have parsed 'owner/repo/extra' and broken gh repo sync downstream). - lib/gitRemote.test.js: new case covering extra-path-segment rejection across SCP, https, and ssh:// shapes.
dd86511 to
2d8ae96
Compare
…vailable - update.sh / update.ps1: scrub embedded user/token credentials from the origin URL before logging so PATs in https://user:token@host/... don't leak into data/update.log or the update UI step output. - lib/gitRemote.js: normalize host by stripping :port, so ssh://github.com:443 and https://github.com:443 still classify as GitHub. Also accept the SCP variant git@ssh.github.com:443/owner/repo.git that GitHub uses for the SSH-over-443 fallback. - routes/update.js: wrap getRemoteInfo() so a spawn failure (git binary missing, etc.) surfaces as a structured 502 GIT_UNAVAILABLE instead of bubbling to an unclassified 500. - lib/gitRemote.test.js: 3 new cases — port stripping for ssh:// + https://, SCP-with-port variant, and getOriginInfo classifying an upstream with port as github.com.
- lib/gitRemote.js: new exported redactRemoteUrlCredentials() helper that strips https://user:token@ prefixes; getOriginInfo() now returns the redacted URL via originUrl so embedded PATs can't leak to the browser, API responses, or downstream telemetry that captures them. - lib/gitRemote.test.js: 6 unit tests for the redactor across https/ssh /SCP/no-cred shapes, plus 2 regression tests asserting credentials never appear anywhere in getOriginInfo()'s output (belt-and-suspenders JSON.stringify check).
…pdate.log - lib/gitRemote.js: isFork now requires same repo name (case-insensitive) AND different owner — was previously isGithub && !isUpstream, which marked any non-upstream GitHub origin as a 'fork' (e.g. someone forked + renamed, or pointed origin at an unrelated repo). gh repo sync would fail with a confusing 502 in that case. Updated JSDoc. - services/updateChecker.js: syncFork() refuses when info.isFork is false with a clear message naming the missing-repo-name condition. - routes/update.js: matching 400 NOT_A_FORK pre-flight in /sync-fork so callers get a structured refusal instead of leaning on string-match. - update.sh / update.ps1: append the redacted origin URL directly to $UPDATE_LOG / $UpdateLog. updateExecutor only forwards STEP: lines, so the prior log()/Write-SafeHost calls never reached update.log. - Tests: NOT-a-fork classification case, syncFork refusal when isFork is false.
…runUpdate - services/updateChecker.js: use UPSTREAM_FULL_NAME in syncFork refusal messages instead of the hardcoded 'atomantic/PortOS' string. Updated the JSDoc to accurately describe the route's error classification — the route runs an upfront 400 gate (NO_ORIGIN / NOT_GITHUB / ALREADY_UPSTREAM / NOT_A_FORK) and only string-matches err.message to distinguish the 409 diverged-fork case from the 502 catch-all. - routes/update.js: import + use UPSTREAM_FULL_NAME in the same way. - UpdateTab.jsx: fetchStatus now returns the fetched status data; handleSyncForkAndUpdate passes that fresh object directly into runUpdate via a new fromStatus parameter so runUpdate isn't relying on the closure-captured (stale) status while setStatus is still scheduling its render.
…s window - services/updateChecker.js: new FORK_SYNC_FRESHNESS_MS exported constant. getUpdateStatus() now returns a server-computed forkSyncFresh boolean (and forkSyncWindowMs for callers that want the raw window) so the UI doesn't reimplement the time math + fullName match. syncFork() accepts an optional pre-fetched remoteInfo to skip the second 'git remote get-url origin' spawn the /sync-fork route used to trigger. - routes/update.js: pass info into syncFork() to avoid the double lookup. /execute fork-gate now reads status.forkSyncFresh (single source of truth) instead of duplicating the 10-min math inline. - UpdateTab.jsx: derive forkSyncFresh from status.forkSyncFresh; drop the client-side time-math copy. - Tests: forkSyncFresh window/mismatch/stale cases for getUpdateStatus, syncFork-uses-pre-fetched-remoteInfo case.
GitHub owner/repo names are case-insensitive, so the fullName comparison in getUpdateStatus()'s forkSyncFresh computation must be too. A remote URL like ALICE/PortOS vs stored lastForkSync.fullName 'alice/PortOS' previously flipped the freshness check to false and kept returning 412 on /execute until the user re-synced. Also added a typeof guard so a malformed lastForkSync object can't crash the status route. Test: mixed-case origin (ALICE/PortOS) + stored (alice/portos) still matches as fresh.
The /sync-fork route accepts an optional branch parameter, but the 409 error message and the suggested 'gh repo sync ... --force' recovery command hardcoded 'main'. A caller syncing a non-main branch would see misleading guidance and a --force suggestion that targets the wrong branch. Resolve the branch (default 'main' to match syncFork's internal default) and interpolate it into both the message and the suggested command.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Detect whether the local clone's
originremote is the upstreamatomantic/PortOSor a personal fork, and adjust the update flow accordingly so fork users can keep running a custom PortOS without being silently railroaded back to upstream — or worse, clicking "Update" and getting a no-op because their fork'smainhasn't been synced.What changed
New
server/lib/gitRemote.js— pure helper that parses git remote URLs (SCP-stylegit@host:owner/repo.git, HTTPS,ssh://, including embedded credentials) and classifies the localoriginas{ isUpstream, isFork, isGithub, owner, repo, fullName }. ExposesUPSTREAM_OWNER/UPSTREAM_REPO/UPSTREAM_FULL_NAMEconstants so we stop hardcodingatomantic/PortOSin multiple places. Case-insensitive owner/repo comparison.Update tab swaps controls on a fork. When
remoteInfo.isForkis true, the single "Update Now" button is replaced with three explicit buttons:gh repo sync <owner>/<fork> --source atomantic/PortOS --branch main, then proceeds with the local update. Fast-forward only.acknowledgeFork: trueto the server. Use when you've already merged upstream into your fork through your own workflow.A banner at the top of the tab shows which repo the running clone came from, with guidance on PR'ing shareable fixes upstream and keeping private changes on a separate branch.
POST /api/update/sync-fork— new endpoint that wrapsgh repo sync. Returns 409FORK_DIVERGEDwhen the fork'smainhas commits not on upstream, with an actionable error message pointing the user at PRs, feature-branch rebases, or the explicit--forcethey can run from a terminal. Never adds--forceserver-side.POST /api/update/executegates fork runs. IfremoteInfo.isForkis true, returns 412FORK_SYNC_REQUIREDunless the body hasacknowledgeFork: trueOR there's a recentlastForkSync(≤10 min, same fork fullName) on file. Prevents the silent "I clicked Update and my version didn't change" failure mode.Release-check still polls upstream.
checkForUpdate()continues to queryatomantic/PortOS/releases/latestso fork users see upstream version notifications. Only the pull/checkout behavior has changed (and that's still pull-from-origin —update.shwas always doing this; we're just no longer pretending otherwise in the UI).update.sh/update.ps1now log the activeoriginURL so the update log makes it obvious which repo the script pulled from.API additions
GET /api/update/statusresponse gainsremoteInfo(origin classification) andupstream({ owner, repo, fullName }).POST /api/update/sync-fork—{ branch? }body, returns{ synced, alreadyUpToDate, fullName, source, mergedBranch, message }. 400 / 409 / 502 errors classified.POST /api/update/execute— accepts optional{ acknowledgeFork: boolean }. New 412FORK_SYNC_REQUIREDresponse.Test plan
server/lib/gitRemote.test.js— 17 cases for URL parsing edge cases (SCP, HTTPS with creds, ssh://, enterprise hosts, case-insensitive upstream match, malformed URLs).server/services/updateChecker.test.js— 12 new cases coveringgetRemoteInfo,getUpdateStatus.remoteInfo, and everysyncForkbranch (success, already-up-to-date, custom branch arg, no-origin refusal, non-GitHub refusal, already-upstream refusal, diverged-fork error propagation, lastForkSync persistence).atomanticfork and verify all three button paths.maincommits — confirm the 409 message tells the user how to recover.