Skip to content

feat: fork-aware update flow#455

Merged
atomantic merged 9 commits into
mainfrom
worktree-fork-aware-update
May 23, 2026
Merged

feat: fork-aware update flow#455
atomantic merged 9 commits into
mainfrom
worktree-fork-aware-update

Conversation

@atomantic
Copy link
Copy Markdown
Owner

Summary

Detect whether the local clone's origin remote is the upstream atomantic/PortOS or 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's main hasn't been synced.

What changed

New server/lib/gitRemote.js — pure helper that parses git remote URLs (SCP-style git@host:owner/repo.git, HTTPS, ssh://, including embedded credentials) and classifies the local origin as { isUpstream, isFork, isGithub, owner, repo, fullName }. Exposes UPSTREAM_OWNER / UPSTREAM_REPO / UPSTREAM_FULL_NAME constants so we stop hardcoding atomantic/PortOS in multiple places. Case-insensitive owner/repo comparison.

Update tab swaps controls on a fork. When remoteInfo.isFork is true, the single "Update Now" button is replaced with three explicit buttons:

  • Sync Fork & Update — runs gh repo sync <owner>/<fork> --source atomantic/PortOS --branch main, then proceeds with the local update. Fast-forward only.
  • Sync Fork Only — same sync, no update afterward (useful when you want to merge upstream into a feature branch yourself before applying).
  • Update from Fork As-Is — skip the sync, just pull from your fork's origin. Sends acknowledgeFork: true to 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 wraps gh repo sync. Returns 409 FORK_DIVERGED when the fork's main has commits not on upstream, with an actionable error message pointing the user at PRs, feature-branch rebases, or the explicit --force they can run from a terminal. Never adds --force server-side.

POST /api/update/execute gates fork runs. If remoteInfo.isFork is true, returns 412 FORK_SYNC_REQUIRED unless the body has acknowledgeFork: true OR there's a recent lastForkSync (≤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 query atomantic/PortOS/releases/latest so fork users see upstream version notifications. Only the pull/checkout behavior has changed (and that's still pull-from-origin — update.sh was always doing this; we're just no longer pretending otherwise in the UI).

update.sh / update.ps1 now log the active origin URL so the update log makes it obvious which repo the script pulled from.

API additions

  • GET /api/update/status response gains remoteInfo (origin classification) and upstream ({ 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 412 FORK_SYNC_REQUIRED response.

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 covering getRemoteInfo, getUpdateStatus.remoteInfo, and every syncFork branch (success, already-up-to-date, custom branch arg, no-origin refusal, non-GitHub refusal, already-upstream refusal, diverged-fork error propagation, lastForkSync persistence).
  • Full server test suite: 6954 tests pass.
  • Full client test suite: 385 tests pass.
  • Manual: run from a non-atomantic fork and verify all three button paths.
  • Manual: try a sync against a fork with divergent main commits — confirm the 409 message tells the user how to recover.

Copilot AI review requested due to automatic review settings May 23, 2026 17:27
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.js to parse/classify the local origin remote and centralize upstream constants.
  • Adds /api/update/sync-fork (wraps gh repo sync) and gates /api/update/execute for 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.

Comment thread server/routes/update.js Outdated
Comment thread server/routes/update.js Outdated
Comment thread server/services/updateChecker.js Outdated
Comment thread server/lib/gitRemote.js
Comment thread server/lib/gitRemote.js Outdated
atomantic added 2 commits May 23, 2026 10:33
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.
@atomantic atomantic force-pushed the worktree-fork-aware-update branch from dd86511 to 2d8ae96 Compare May 23, 2026 17:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Comment thread update.sh
Comment thread update.ps1
Comment thread server/lib/gitRemote.js
Comment thread server/lib/gitRemote.js Outdated
Comment thread server/routes/update.js
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread server/lib/gitRemote.js
- 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).
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment thread server/lib/gitRemote.js Outdated
Comment thread server/services/updateChecker.js
Comment thread update.sh
Comment thread update.ps1
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Comment thread server/routes/update.js Outdated
Comment thread server/services/updateChecker.js Outdated
Comment thread client/src/components/apps/tabs/UpdateTab.jsx
Comment thread server/services/updateChecker.js Outdated
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread server/routes/update.js
Comment thread server/routes/update.js Outdated
…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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread server/services/updateChecker.js Outdated
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comment thread server/routes/update.js Outdated
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.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

@atomantic atomantic merged commit f2c57b7 into main May 23, 2026
6 checks passed
@atomantic atomantic deleted the worktree-fork-aware-update branch May 23, 2026 18:19
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