Skip to content

feat: allow restore-from-gist URL without token; fall back to public …#119

Merged
shinokada merged 13 commits intomainfrom
fix/restore-from-gist
Mar 12, 2026
Merged

feat: allow restore-from-gist URL without token; fall back to public …#119
shinokada merged 13 commits intomainfrom
fix/restore-from-gist

Conversation

@shinokada
Copy link
Owner

@shinokada shinokada commented Mar 11, 2026

…fetch

Closes #

📑 Description

✅ Checks

  • My pull request adheres to the code style of this project
  • My code requires changes to the documentation
  • I have updated the documentation as required
  • All the tests have passed

ℹ Additional Information

Summary by CodeRabbit

  • New Features

    • Restore or pull selected files from a Gist by URL/ID, including a direct restore path that works without the sync manager.
    • Preview available categories/files from a Gist or raw gist data without signing in.
  • Improvements

    • URL-based restore UI with input/URL flow, preserved pending state, fetch progress, keyboard handling, and unauthenticated fallback.
    • Safer atomic file writes for staged updates, backup, and restore to reduce corruption risk.
    • Broader gist inspection and restore flows accepting both authenticated and unauthenticated fetches.
  • Bug Fixes

    • Improved conflict detection and clearer overwrite/force handling before restore.

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 475f7e58-0625-445d-b72e-f22576ddec2a

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:

  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds unauthenticated URL-based Gist restore/pull flows, centralizes gist filename→category mapping (categorizePath), adds conflict detection and direct restore/pull helpers, and introduces an atomic write helper (atomicWriteFile) used for staged, atomic file updates. UI gains a Gist-URL input state and pending-gist async handling.

Changes

Cohort / File(s) Summary
Backend Gist Operations
internal/storage/gist_sync.go
Centralizes config dir resolution via teraConfigDir; adds categorizePath; new funcs: AvailableCategoriesFromGistFiles, AvailableCategoriesFromGist, ConflictingFilesForGist, RestoreFromGistDirect, stageAndWriteGistFiles; adds method (*GistSyncManager).PullFromGist; swaps direct writes to atomicWriteFile, integrates fetchRawContent, and implements force/conflict semantics.
Atomic write utility
internal/storage/atomic_write.go
New non-exported atomicWriteFile: writes to a temp file in destination dir, flushes/closes/chmods, renames atomically, does best-effort parent-dir sync, and cleans up on failure.
History write cleanup
internal/storage/history.go
Removed inline atomic-write helper and runtime import; now relies on centralized atomicWriteFile.
Lock comment
internal/storage/lock.go
Added comment referencing atomicWriteFile location; no behavioral change.
Backup/Zip extraction
internal/storage/backup.go
Zip extraction now reads entries fully and writes via atomicWriteFile (0600); simplified resource handling and updated error messages.
UI Gist Restore Flow
internal/ui/gist.go
Adds gistStateRestoreGistURL UI, pending-gist handling (pendingGist, gistFetchPending, gistFetchSeq), expands messages (gistRestoreAvailableMsg, gistConflictCheckMsg) to carry *gist.Gist and requestID; updates async commands and restore flow to accept gist references and support authenticated + unauthenticated fetch/restore paths; keyboard/navigation tweaks for URL flow.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI as "UI (internal/ui/gist.go)"
    participant Storage as "Storage (internal/storage/gist_sync.go)"
    participant GistAPI as "Gist API"

    User->>UI: Enter Gist URL/ID
    UI->>UI: parse gistID, set gistFetchPending, bump gistFetchSeq
    UI->>Storage: doFetchAvailableGistCategoriesCmd(requestID, gistID)
    Storage->>GistAPI: Fetch gist (auth if available, else unauth)
    GistAPI-->>Storage: Return gist files
    Storage-->>UI: gistRestoreAvailableMsg(gist, categories, requestID)
    UI->>User: Show categories
    User->>UI: Select categories, confirm
    UI->>Storage: doCheckGistConflictsCmd(gist, prefs)
    Storage->>Storage: ConflictingFilesForGist(compare gist vs local via teraConfigDir)
    Storage-->>UI: gistConflictCheckMsg(gist, prefs, conflicts)
    alt No conflicts or force
        UI->>Storage: restoreGistCmd(gist, prefs, force)
        Storage->>Storage: RestoreFromGistDirect / PullFromGist (fetch raw content)
        Storage->>Storage: stageAndWriteGistFiles -> atomicWriteFile(write staged files)
        Storage-->>UI: Restore complete
        UI->>User: Display success
    else Conflicts
        UI->>User: Prompt resolution
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Poem

🐰 I found a Gist beneath a log,
I parse each path and map each name.
I fetch the bytes, I stage with care,
then swap them in — no bits to spare.
Hop, sync, and stash: safe in the rain. 🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically summarizes the main feature added: support for restore-from-gist operations without requiring an authentication token by falling back to public fetch.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/storage/gist_sync.go (1)

369-423: Consider extracting shared category-detection logic.

The switch-case logic for mapping gist filenames to SyncPrefs categories is duplicated across three locations:

  1. AvailableCategories (lines 172-192)
  2. AvailableCategoriesFromGist (lines 376-394)
  3. AvailableCategoriesFromGistFiles (lines 405-420)

All three iterate over file names and apply the same mapping. A helper function could reduce duplication:

♻️ Suggested refactor to extract common logic
// categorizePath updates prefs based on a single gist filename.
func categorizePath(name string, prefs *SyncPrefs) {
	relPath := gistFilenameToRelPath(name)
	switch {
	case relPath == "config.yaml":
		prefs.Settings = true
	case relPath == filepath.Join("data", "favorites", SystemFileSearchHistory):
		prefs.SearchHistory = true
	case strings.HasPrefix(filepath.ToSlash(relPath), "data/favorites/"):
		prefs.Favorites = true
	case relPath == filepath.Join("data", "station_ratings.json") ||
		relPath == filepath.Join("data", "voted_stations.json"):
		prefs.RatingsVotes = true
	case relPath == filepath.Join("data", "blocklist.json"):
		prefs.Blocklist = true
	case relPath == filepath.Join("data", "station_metadata.json") ||
		relPath == filepath.Join("data", "station_tags.json"):
		prefs.MetadataTags = true
	}
}

// AvailableCategoriesFromGistFiles can then become:
func AvailableCategoriesFromGistFiles(files map[string]gist.GistFile) SyncPrefs {
	var prefs SyncPrefs
	for name := range files {
		categorizePath(name, &prefs)
	}
	return prefs
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 369 - 423, The duplicate
switch/categorization logic in AvailableCategories, AvailableCategoriesFromGist,
and AvailableCategoriesFromGistFiles should be extracted into a single helper
(e.g., categorizePath(name string, prefs *SyncPrefs)) that calls
gistFilenameToRelPath and updates the SyncPrefs flags; then replace each loop
body to call categorizePath for each filename and return the aggregated prefs,
keeping existing function signatures (AvailableCategories,
AvailableCategoriesFromGist, AvailableCategoriesFromGistFiles) unchanged.
internal/ui/gist.go (1)

1042-1057: Note: Signature mismatch if RestoreFromGistDirect gains a force parameter.

If RestoreFromGistDirect is updated to accept a force parameter as suggested in the storage review, this call site will need to be updated accordingly:

-		if err := storage.RestoreFromGistDirect(g, prefs); err != nil {
+		if err := storage.RestoreFromGistDirect(g, prefs, force); err != nil {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/gist.go` around lines 1042 - 1057, The call site in
restoreGistCmd needs updating to match the new storage.RestoreFromGistDirect
signature: if storage.RestoreFromGistDirect is extended to accept a force bool,
pass the existing force variable through when calling it in the
standalone-restore branch (the same way PullFromGist is invoked), so update the
call in GistModel.restoreGistCmd to call storage.RestoreFromGistDirect(g, prefs,
force) and preserve the current error handling and successMsg behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/storage/gist_sync.go`:
- Around line 425-463: RestoreFromGistDirect currently writes staged files
directly and can silently overwrite existing files; add a pre-write conflict
detection step that inspects each staged entry (staged map) against the existing
destination (baseDir + relPath) using os.Stat/os.ReadFile and byte-equality
comparison, collect any differing paths into a conflicts slice and return a
descriptive error (or abort) if non-empty instead of writing; implement this as
a small helper like detectGistConflicts(staged map[string][]byte, baseDir
string) ([]string, error) and call it in RestoreFromGistDirect before creating
directories/writing files so restores without a manager/token still report/avoid
overwrites.

In `@internal/ui/gist.go`:
- Around line 1027-1040: The doCheckGistConflictsCmd currently skips conflict
detection when mgr == nil which returns gistConflictCheckMsg with conflicts=nil
and allows restores to proceed silently; update doCheckGistConflictsCmd to call
the standalone conflict checker you added for RestoreFromGistDirect (instead of
returning early) so it always computes conflicts: locate doCheckGistConflictsCmd
and replace the mgr==nil branch to invoke the new checker (the same logic used
by RestoreFromGistDirect), return any error as errMsg (as you do for
ConflictingGistFiles), and return gistConflictCheckMsg with the checker’s
conflicts so overwrite warnings are shown even when no mgr/token exists.

---

Nitpick comments:
In `@internal/storage/gist_sync.go`:
- Around line 369-423: The duplicate switch/categorization logic in
AvailableCategories, AvailableCategoriesFromGist, and
AvailableCategoriesFromGistFiles should be extracted into a single helper (e.g.,
categorizePath(name string, prefs *SyncPrefs)) that calls gistFilenameToRelPath
and updates the SyncPrefs flags; then replace each loop body to call
categorizePath for each filename and return the aggregated prefs, keeping
existing function signatures (AvailableCategories, AvailableCategoriesFromGist,
AvailableCategoriesFromGistFiles) unchanged.

In `@internal/ui/gist.go`:
- Around line 1042-1057: The call site in restoreGistCmd needs updating to match
the new storage.RestoreFromGistDirect signature: if
storage.RestoreFromGistDirect is extended to accept a force bool, pass the
existing force variable through when calling it in the standalone-restore branch
(the same way PullFromGist is invoked), so update the call in
GistModel.restoreGistCmd to call storage.RestoreFromGistDirect(g, prefs, force)
and preserve the current error handling and successMsg behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8eb8a1b4-7c0b-4c94-ad16-ccfac1406ab8

📥 Commits

Reviewing files that changed from the base of the PR and between a5218b3 and 50933b6.

📒 Files selected for processing (2)
  • internal/storage/gist_sync.go
  • internal/ui/gist.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/storage/gist_sync.go (2)

340-347: ⚠️ Potential issue | 🟠 Major

The restore write path is still crash-unsafe.

Staging downloads only protects the fetch phase. Once these loops start, a later failure can leave some files updated and others old, and os.WriteFile can truncate an existing file if the process dies mid-write. At minimum, switch these writes to atomicWriteFile; otherwise the “mid-restore failure” comment overstates the safety of this flow.

Use the existing atomic write helper
-		if err := os.WriteFile(destPath, content, 0600); err != nil {
+		if err := atomicWriteFile(destPath, content, 0600); err != nil {
 			return fmt.Errorf("failed to write %s: %w", relPath, err)
 		}

Apply the same change in both restore loops.

Also applies to: 466-473

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 340 - 347, The restore loop that
writes staged files uses os.WriteFile and is crash-unsafe; replace the direct
writes in the loop iterating over staged (using destPath :=
filepath.Join(m.configDir, relPath)) with the existing atomicWriteFile helper so
each file is written atomically (preserve the 0600 mode and keep the
os.MkdirAll(dir, 0755) before writing); make the same change in the second
restore loop (the other occurrence around the 466-473 region) so both restore
paths use atomicWriteFile instead of os.WriteFile.

297-309: ⚠️ Potential issue | 🟠 Major

Reject non-backup gists before restoring.

FindBackupGist only accepts gists that contain tera-manifest.json, but these new direct restore paths skip that guard entirely. A random gist that happens to contain config.yaml or metadata.json will now be treated as restorable and can overwrite local state.

Suggested guard
 func (m *GistSyncManager) PullFromGist(g *gist.Gist, prefs SyncPrefs, force bool) error {
 	if g == nil {
 		return fmt.Errorf("gist is required")
 	}
+	if _, ok := g.Files[backupGistMarkerFile]; !ok {
+		return fmt.Errorf("gist is not a tera backup")
+	}
 
 	if !force {
 		conflicts, err := m.ConflictingGistFiles(g, prefs)
 		if err != nil {
 			return err
@@
 func RestoreFromGistDirect(g *gist.Gist, prefs SyncPrefs, force bool) error {
 	if g == nil {
 		return fmt.Errorf("gist is required")
 	}
+	if _, ok := g.Files[backupGistMarkerFile]; !ok {
+		return fmt.Errorf("gist is not a tera backup")
+	}
 	if !force {
 		conflicts, err := ConflictingFilesForGist(g, prefs)
 		if err != nil {
 			return err
 		}

Also applies to: 432-443

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 297 - 309, The PullFromGist path
currently trusts any gist containing config.yaml/metadata.json and can overwrite
local state; update GistSyncManager.PullFromGist to reject non-backup gists by
verifying the gist contains the backup manifest (tera-manifest.json) before
proceeding (e.g., reuse the same check logic as FindBackupGist or add a helper
like isBackupGist), and return a clear error when the manifest is missing; apply
the same manifest-check guard to the other restore path that currently skips it
(the similar restore function around the later diff) and keep existing conflict
checks (ConflictingGistFiles/RestoreConflictError) intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/gist.go`:
- Around line 513-523: The code currently sets m.state = gistStateSyncProgress
before invoking doFetchAvailableGistCategoriesCmd(gistID), which causes errors
to be handled from the progress state and drop the user out of the URL form;
instead, keep the current URL-entry state and invoke
doFetchAvailableGistCategoriesCmd without changing m.state, show a transient
m.message/m.messageIsError to inform the user while the fetch runs, and only
switch m.state to gistStateSyncProgress inside the success path of the command
(or have the command send a message that the Update handler uses to set m.state
on success); update the error handling in the command's result to set
m.message/m.messageIsError and leave m.state unchanged so the user stays on the
input form for correction/retry.

---

Outside diff comments:
In `@internal/storage/gist_sync.go`:
- Around line 340-347: The restore loop that writes staged files uses
os.WriteFile and is crash-unsafe; replace the direct writes in the loop
iterating over staged (using destPath := filepath.Join(m.configDir, relPath))
with the existing atomicWriteFile helper so each file is written atomically
(preserve the 0600 mode and keep the os.MkdirAll(dir, 0755) before writing);
make the same change in the second restore loop (the other occurrence around the
466-473 region) so both restore paths use atomicWriteFile instead of
os.WriteFile.
- Around line 297-309: The PullFromGist path currently trusts any gist
containing config.yaml/metadata.json and can overwrite local state; update
GistSyncManager.PullFromGist to reject non-backup gists by verifying the gist
contains the backup manifest (tera-manifest.json) before proceeding (e.g., reuse
the same check logic as FindBackupGist or add a helper like isBackupGist), and
return a clear error when the manifest is missing; apply the same manifest-check
guard to the other restore path that currently skips it (the similar restore
function around the later diff) and keep existing conflict checks
(ConflictingGistFiles/RestoreConflictError) intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: c137342b-d04d-41a0-9241-a8631762afd3

📥 Commits

Reviewing files that changed from the base of the PR and between 50933b6 and 298e8b7.

📒 Files selected for processing (2)
  • internal/storage/gist_sync.go
  • internal/ui/gist.go

@shinokada
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/ui/gist.go (1)

267-283: ⚠️ Potential issue | 🟠 Major

Don't send every gistStateSyncProgress failure back to the URL form.

Line 280 now rewrites all progress-state errors to gistStateRestoreGistURL, but that state is also used by zip export, zip restore, sync-to-gist, and the actual restore command. A failed export or zip restore will now dump the user onto the gist URL screen. The URL fetch path no longer enters gistStateSyncProgress, so this branch never fixes the retry case it was added for.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/gist.go` around lines 267 - 283, The error branch for errMsg
currently maps any gistStateSyncProgress failure to gistStateRestoreGistURL
which incorrectly redirects failures from zip export/restore and other flows;
instead, only redirect to gistStateRestoreGistURL if the sync was actually
triggered from the URL form (or the prior state was gistStateRestoreGistURL).
Update the errMsg handling in the switch so the gistStateSyncProgress case
checks a reliable indicator (e.g., a new boolean field like
m.syncTriggeredFromURL or an existing prior-state field such as m.prevState) and
only set m.state = gistStateRestoreGistURL when that indicator shows the URL
flow initiated the sync; otherwise restore the appropriate state (e.g.,
gistStateMenu or m.prevState) so export/restore flows are not dropped onto the
URL screen.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/storage/atomic_write.go`:
- Around line 48-53: After renaming the temp file with os.Rename(tmpPath, path)
you must fsync the parent directory so the directory entry is durable; open the
parent directory (using the target path's directory), call Sync() and close it,
handle and return any error from that Sync, and only set success = true after
the directory Sync succeeds—this complements the existing tmp.Sync() and ensures
the rename is not lost on crash.

In `@internal/storage/gist_sync.go`:
- Around line 382-401: AvailableCategoriesFromGist and
AvailableCategoriesFromGistFiles must first verify the backup marker file
("tera-manifest.json") exists before inspecting filenames; if the marker is
missing, return an empty SyncPrefs immediately to avoid treating arbitrary gists
as restorables. Update AvailableCategoriesFromGist to check g.Files for
"tera-manifest.json" and return SyncPrefs{} (no error) when absent, and update
AvailableCategoriesFromGistFiles to do the same on the files map, then proceed
to call categorizePath for each name only when the marker is present.

---

Outside diff comments:
In `@internal/ui/gist.go`:
- Around line 267-283: The error branch for errMsg currently maps any
gistStateSyncProgress failure to gistStateRestoreGistURL which incorrectly
redirects failures from zip export/restore and other flows; instead, only
redirect to gistStateRestoreGistURL if the sync was actually triggered from the
URL form (or the prior state was gistStateRestoreGistURL). Update the errMsg
handling in the switch so the gistStateSyncProgress case checks a reliable
indicator (e.g., a new boolean field like m.syncTriggeredFromURL or an existing
prior-state field such as m.prevState) and only set m.state =
gistStateRestoreGistURL when that indicator shows the URL flow initiated the
sync; otherwise restore the appropriate state (e.g., gistStateMenu or
m.prevState) so export/restore flows are not dropped onto the URL screen.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8827688f-434f-410c-97bc-faa27f924045

📥 Commits

Reviewing files that changed from the base of the PR and between 298e8b7 and 195d50a.

📒 Files selected for processing (5)
  • internal/storage/atomic_write.go
  • internal/storage/gist_sync.go
  • internal/storage/history.go
  • internal/storage/lock.go
  • internal/ui/gist.go
💤 Files with no reviewable changes (1)
  • internal/storage/history.go
✅ Files skipped from review due to trivial changes (1)
  • internal/storage/lock.go

Comment on lines +48 to +53
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to rename temp file to destination: %w", err)
}

success = true
return nil
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sync the parent directory after Line 48.

tmp.Sync() only makes the temp file durable. The rename itself is still vulnerable until the containing directory entry is flushed, so a crash right after Line 48 can lose the new file even though the helper returned success.

💡 Minimal durability fix
 	if err := os.Rename(tmpPath, path); err != nil {
 		return fmt.Errorf("failed to rename temp file to destination: %w", err)
 	}
+	dirHandle, err := os.Open(dir)
+	if err != nil {
+		return fmt.Errorf("failed to open parent directory: %w", err)
+	}
+	defer dirHandle.Close()
+	if err := dirHandle.Sync(); err != nil {
+		return fmt.Errorf("failed to sync parent directory: %w", err)
+	}
 
 	success = true
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/atomic_write.go` around lines 48 - 53, After renaming the
temp file with os.Rename(tmpPath, path) you must fsync the parent directory so
the directory entry is durable; open the parent directory (using the target
path's directory), call Sync() and close it, handle and return any error from
that Sync, and only set success = true after the directory Sync succeeds—this
complements the existing tmp.Sync() and ensures the rename is not lost on crash.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/storage/gist_sync.go`:
- Around line 297-303: PullFromGist currently assumes g.Files is populated and
returns “missing tera-manifest.json” for listing responses; before checking for
backupGistMarkerFile (and similarly in AvailableCategoriesFromGist), detect when
len(g.Files) == 0 and call the GitHub client’s GetGist method (same approach
used in ConflictingGistFiles) to fetch the full gist, replace or update g with
the full result, then proceed with the existing marker/file checks (referenced
symbols: PullFromGist, AvailableCategoriesFromGist, ConflictingGistFiles,
gist.Gist.Files, GetGist, backupGistMarkerFile).

In `@internal/ui/gist.go`:
- Around line 364-367: The handler for gistRestoreAvailableMsg should ignore
results if the user has already left the URL form: before setting m.pendingGist,
m.checklist, and m.state = gistStateRestoreGistChecklist, verify the current
m.state is gistStateRestoreGistURL (or another expected pre-fetch state) so
stale async results from doFetchAvailableGistCategoriesCmd are dropped; if the
state matches, set m.pendingGist and m.checklist via availableChecklist and
transition m.state to gistStateRestoreGistChecklist and also clear the "Fetching
Gist…" progress text (e.g., reset whatever status/progress field shows that
message) so a cancelled fetch doesn't leave a stale progress message visible.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 3d117474-f71c-43a3-bf38-70745c7a9dc3

📥 Commits

Reviewing files that changed from the base of the PR and between 195d50a and cdbd9c4.

📒 Files selected for processing (3)
  • internal/storage/atomic_write.go
  • internal/storage/gist_sync.go
  • internal/ui/gist.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/storage/atomic_write.go

@shinokada
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
internal/ui/gist.go (1)

364-374: ⚠️ Potential issue | 🟠 Major

Correlate URL-fetch responses with the active request.

The URL form stays interactive while doFetchAvailableGistCategoriesCmd runs, but the command returns unversioned success/error messages. If the user presses Enter twice or cancels with Esc, an older request can still open the checklist or replace the current screen message after cancel. Please return a dedicated fetch result message carrying a request ID (and error), ignore anything that's no longer current, and clear the transient "Fetching Gist…" status when the URL flow is cancelled.

Also applies to: 519-540, 986-1021

internal/storage/gist_sync.go (1)

426-449: ⚠️ Potential issue | 🟡 Minor

Hydrate sparse gists in the standalone helpers too.

ConflictingFilesForGist and RestoreFromGistDirect still assume g.Files is populated. Passing a summary gist here yields either no conflicts or a false missing-marker error, unlike PullFromGist and AvailableCategoriesFromGist, which already hydrate first.

💡 Suggested fix
 func ConflictingFilesForGist(g *gist.Gist, prefs SyncPrefs) ([]string, error) {
 	if g == nil {
 		return nil, fmt.Errorf("gist is required")
 	}
+	if len(g.Files) == 0 {
+		full, err := gist.GetGistPublic(g.ID)
+		if err != nil {
+			return nil, fmt.Errorf("failed to fetch gist: %w", err)
+		}
+		g = full
+	}
 	configDir, err := os.UserConfigDir()
 	if err != nil {
 		return nil, fmt.Errorf("failed to get config directory: %w", err)
 	}
@@
 func RestoreFromGistDirect(g *gist.Gist, prefs SyncPrefs, force bool) error {
 	if g == nil {
 		return fmt.Errorf("gist is required")
 	}
+	if len(g.Files) == 0 {
+		full, err := gist.GetGistPublic(g.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch gist: %w", err)
+		}
+		g = full
+	}
 	if _, ok := g.Files[backupGistMarkerFile]; !ok {
 		return fmt.Errorf("gist is not a tera backup (missing %s)", backupGistMarkerFile)
 	}

Also applies to: 455-460

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 426 - 449,
ConflictingFilesForGist (and similarly RestoreFromGistDirect) assumes g.Files is
populated but may be given a summary gist; before using g.Files, perform the
same gist-hydration step used by PullFromGist and AvailableCategoriesFromGist
(i.e. fetch/expand the full gist so g.Files is populated) and return any fetch
error; then proceed to compute conflicts as before so summary gists no longer
produce missing/empty results.
🧹 Nitpick comments (1)
internal/storage/gist_sync.go (1)

430-435: Use one shared config-dir resolver for the no-token path.

These helpers rebuild filepath.Join(os.UserConfigDir(), "tera") inline. Please route that through a shared resolver so restore/conflict checks stay aligned with the app's normal config-dir lookup and remain easy to test.

Also applies to: 471-475

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 430 - 435, The code constructs
the app config path inline using os.UserConfigDir() + "tera" (configDir/baseDir)
for the no-token path; replace these inline constructions with the app's shared
config-dir resolver so both the restore/conflict logic and the normal lookup use
the same implementation. Concretely, remove the
os.UserConfigDir()/filepath.Join(...) usage in the no-token branches and call
the shared resolver function used elsewhere (e.g. ResolveConfigDir or
GetAppConfigDir) to obtain the base config directory, then use that return value
where configDir/baseDir were used; apply the same change for the second
occurrence mentioned around the later block so both sites use the single
resolver.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@internal/storage/gist_sync.go`:
- Around line 426-449: ConflictingFilesForGist (and similarly
RestoreFromGistDirect) assumes g.Files is populated but may be given a summary
gist; before using g.Files, perform the same gist-hydration step used by
PullFromGist and AvailableCategoriesFromGist (i.e. fetch/expand the full gist so
g.Files is populated) and return any fetch error; then proceed to compute
conflicts as before so summary gists no longer produce missing/empty results.

---

Nitpick comments:
In `@internal/storage/gist_sync.go`:
- Around line 430-435: The code constructs the app config path inline using
os.UserConfigDir() + "tera" (configDir/baseDir) for the no-token path; replace
these inline constructions with the app's shared config-dir resolver so both the
restore/conflict logic and the normal lookup use the same implementation.
Concretely, remove the os.UserConfigDir()/filepath.Join(...) usage in the
no-token branches and call the shared resolver function used elsewhere (e.g.
ResolveConfigDir or GetAppConfigDir) to obtain the base config directory, then
use that return value where configDir/baseDir were used; apply the same change
for the second occurrence mentioned around the later block so both sites use the
single resolver.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 0811c9a4-2841-47f3-a81e-cb261c074751

📥 Commits

Reviewing files that changed from the base of the PR and between cdbd9c4 and 4099a1a.

📒 Files selected for processing (2)
  • internal/storage/gist_sync.go
  • internal/ui/gist.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/storage/gist_sync.go (1)

427-432: Reuse the canonical config-dir resolver here.

internal/config/loader.go:16-27 already owns this logic. Duplicating it here makes teraConfigDir() a second source of truth, so a future change to the app’s config-dir rules can silently split gist restore/sync from the rest of the app. If importing that package would create a cycle, I’d still prefer moving the shared helper lower rather than hardcoding "tera" twice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 427 - 432, teraConfigDir
duplicates the app's config-dir resolution; replace its logic by reusing the
canonical resolver in internal/config/loader.go (or move that shared helper into
a lower-level package if importing causes a cycle). Update references to
teraConfigDir so they call the shared function from internal/config/loader.go
(or the newly extracted helper) instead of duplicating os.UserConfigDir +
"tera", ensuring only one source of truth for the config directory across
gist_sync.go and the rest of the app.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/ui/gist.go`:
- Around line 72-75: The guard using m.state != gistStateRestoreGistURL is
insufficient; instead generate and track a unique request ID when starting a
gist fetch, store it on the component (e.g., m.currentRestoreRequestID), include
that ID in the response messages (e.g., add a requestID string field to
gistRestoreAvailableMsg), and then ignore incoming results whose requestID does
not match m.currentRestoreRequestID before updating state or opening the
checklist; apply the same pattern to the other fetch/response pairs referenced
(the other response message types and their handlers) so stale responses are
fenced off even if the user re-enters the form.

---

Nitpick comments:
In `@internal/storage/gist_sync.go`:
- Around line 427-432: teraConfigDir duplicates the app's config-dir resolution;
replace its logic by reusing the canonical resolver in internal/config/loader.go
(or move that shared helper into a lower-level package if importing causes a
cycle). Update references to teraConfigDir so they call the shared function from
internal/config/loader.go (or the newly extracted helper) instead of duplicating
os.UserConfigDir + "tera", ensuring only one source of truth for the config
directory across gist_sync.go and the rest of the app.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: b053df7f-74e9-4626-80e0-b3beb15a632f

📥 Commits

Reviewing files that changed from the base of the PR and between cdbd9c4 and 1834898.

📒 Files selected for processing (2)
  • internal/storage/gist_sync.go
  • internal/ui/gist.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/storage/gist_sync.go (1)

432-463: ConflictingFilesForGist re-fetches using GetGistPublic which may fail for authenticated gists.

When g.Files is empty, the function calls gist.GetGistPublic(g.ID) (line 439). If the original gist was fetched via an authenticated client (and is a private gist), the public re-fetch will fail with a 404. This is inconsistent with the UI flow where doFetchAvailableGistCategoriesCmd already fetches the full gist and passes it with populated Files.

In practice, the UI always passes a fully-fetched gist here, so this is unlikely to trigger. However, for API consistency and defensive coding:

💡 Consider documenting or removing the empty-files branch

Since callers always provide a fully-fetched gist, you could either:

  1. Document this precondition and remove the re-fetch logic
  2. Accept an optional authenticated client parameter for private gist support
 // ConflictingFilesForGist checks for existing local files that would be
 // overwritten by a restore from the given Gist. Works without a GistSyncManager.
+// The caller must ensure g.Files is populated (e.g., by fetching the full gist first).
 func ConflictingFilesForGist(g *gist.Gist, prefs SyncPrefs) ([]string, error) {
 	if g == nil {
 		return nil, fmt.Errorf("gist is required")
 	}
-	if len(g.Files) == 0 {
-		full, err := gist.GetGistPublic(g.ID)
-		if err != nil {
-			return nil, fmt.Errorf("failed to fetch gist: %w", err)
-		}
-		g = full
-	}
+	if len(g.Files) == 0 {
+		return nil, fmt.Errorf("gist files not populated; fetch full gist first")
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 432 - 463,
ConflictingFilesForGist currently re-fetches the gist via gist.GetGistPublic
when g.Files is empty which breaks for private/authenticated gists; remove that
public re-fetch and instead require a fully-populated gist: if g.Files is empty
return a clear error (or document the precondition) so callers like
doFetchAvailableGistCategoriesCmd must pass the full gist; update
ConflictingFilesForGist and its callers/tests accordingly to avoid
unauthenticated HTTP calls for private gists.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/storage/gist_sync.go`:
- Around line 432-463: ConflictingFilesForGist currently re-fetches the gist via
gist.GetGistPublic when g.Files is empty which breaks for private/authenticated
gists; remove that public re-fetch and instead require a fully-populated gist:
if g.Files is empty return a clear error (or document the precondition) so
callers like doFetchAvailableGistCategoriesCmd must pass the full gist; update
ConflictingFilesForGist and its callers/tests accordingly to avoid
unauthenticated HTTP calls for private gists.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 01028364-a19a-429c-ac10-09b78440272e

📥 Commits

Reviewing files that changed from the base of the PR and between 1834898 and 2e8829c.

📒 Files selected for processing (2)
  • internal/storage/gist_sync.go
  • internal/ui/gist.go

…ion error in ConflictingFilesForGist and RestoreFromGistDirect
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/storage/gist_sync.go (1)

336-359: ⚠️ Potential issue | 🟠 Major

Staging the bytes here is not atomic across the full restore set.

If a later MkdirAll or atomicWriteFile fails, earlier files are already committed and the config tree is left in a mixed snapshot. That means the “everything is ready before writing” comment on Line 336 overstates the guarantee. If partial restores are not acceptable, this needs a two-phase swap or rollback strategy instead of per-file commits.

Also applies to: 497-517

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 336 - 359, The current restore
loop stages file bytes in staged but then writes each file directly into
m.configDir using os.MkdirAll and atomicWriteFile, which can leave a
partially-updated config if any MkdirAll/atomicWriteFile fails; change to a
two-phase replace: create a temporary directory (e.g., m.configDir+".tmp" or use
os.MkdirTemp), write all staged contents into that temp tree using the same
atomicWriteFile and MkdirAll logic, verify success, then atomically swap the
tree into place with os.Rename (or remove the old config and rename the temp),
and ensure cleanup/rollback of the temp dir on any error; apply the same change
for the second block referenced (lines ~497-517) so both restore paths are
atomic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@internal/storage/gist_sync.go`:
- Around line 336-359: The current restore loop stages file bytes in staged but
then writes each file directly into m.configDir using os.MkdirAll and
atomicWriteFile, which can leave a partially-updated config if any
MkdirAll/atomicWriteFile fails; change to a two-phase replace: create a
temporary directory (e.g., m.configDir+".tmp" or use os.MkdirTemp), write all
staged contents into that temp tree using the same atomicWriteFile and MkdirAll
logic, verify success, then atomically swap the tree into place with os.Rename
(or remove the old config and rename the temp), and ensure cleanup/rollback of
the temp dir on any error; apply the same change for the second block referenced
(lines ~497-517) so both restore paths are atomic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8ed32add-e8ec-44ea-bb58-b86eaac3e340

📥 Commits

Reviewing files that changed from the base of the PR and between 2e8829c and 32600fb.

📒 Files selected for processing (1)
  • internal/storage/gist_sync.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/storage/gist_sync.go (1)

498-526: Consider extracting shared fetch-and-write logic.

The staged fetch and atomic write loop (lines 500-526) is nearly identical to lines 336-362 in PullFromGist. A shared helper could reduce duplication:

♻️ Optional refactor idea
// stageAndWriteGistFiles fetches content and writes atomically to baseDir.
func stageAndWriteGistFiles(
    httpClient *http.Client,
    files map[string]gist.GistFile,
    prefs SyncPrefs,
    baseDir string,
) error {
    staged := make(map[string][]byte)
    for name, gistFile := range files {
        relPath := gistFilenameToRelPath(name)
        if relPath == "" || !syncPrefForRelPath(relPath, prefs) {
            continue
        }
        content, err := fetchRawContent(httpClient, gistFile.RawURL, gistFile.Content)
        if err != nil {
            return fmt.Errorf("failed to fetch %s: %w", name, err)
        }
        staged[relPath] = []byte(content)
    }
    for relPath, content := range staged {
        destPath := filepath.Join(baseDir, relPath)
        if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
            return fmt.Errorf("failed to create directory for %s: %w", relPath, err)
        }
        if err := atomicWriteFile(destPath, content, 0600); err != nil {
            return fmt.Errorf("failed to write %s: %w", relPath, err)
        }
    }
    return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 498 - 526, The fetch-and-write
loop in PullFromGist and the block in gist_sync.go duplicate logic; extract a
helper (e.g., stageAndWriteGistFiles) that accepts an *http.Client, files
map[string]gist.GistFile, prefs SyncPrefs and baseDir and does the relPath
filtering with gistFilenameToRelPath, uses fetchRawContent to fetch content,
stages bytes, and writes them with os.MkdirAll + atomicWriteFile; replace both
original blocks (the loop in PullFromGist and the staged loop in gist_sync.go)
with calls to this helper to remove duplication and preserve the same error
messages and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/storage/gist_sync.go`:
- Around line 498-526: The fetch-and-write loop in PullFromGist and the block in
gist_sync.go duplicate logic; extract a helper (e.g., stageAndWriteGistFiles)
that accepts an *http.Client, files map[string]gist.GistFile, prefs SyncPrefs
and baseDir and does the relPath filtering with gistFilenameToRelPath, uses
fetchRawContent to fetch content, stages bytes, and writes them with os.MkdirAll
+ atomicWriteFile; replace both original blocks (the loop in PullFromGist and
the staged loop in gist_sync.go) with calls to this helper to remove duplication
and preserve the same error messages and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 680ae994-3607-48f7-89a3-3344119e696a

📥 Commits

Reviewing files that changed from the base of the PR and between 32600fb and 191bb9b.

📒 Files selected for processing (2)
  • internal/storage/backup.go
  • internal/storage/gist_sync.go

…and-write logic in PullFromGist and RestoreFromGistDirect
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/storage/gist_sync.go (2)

263-275: ⚠️ Potential issue | 🟡 Minor

Only flag differing files as conflicts.

Both helpers currently treat mere existence as an overwrite conflict. If the local file already has the same bytes as the gist, this still returns RestoreConflictError, so a no-op re-run of pull/restore is blocked and retries after a partial success unnecessarily require force=true. Please compare contents and only report paths whose existing bytes differ from the staged gist content.

Also applies to: 414-425

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 263 - 275, The current conflict
detection marks any existing local file as a conflict; instead, read the staged
gist content for each gist entry (use the same map entry referenced by
g.Files[name] or the staged content source used elsewhere in gist_sync.go), read
the local file bytes (os.ReadFile / ioutil.ReadFile) at absPath, and compare
byte-for-byte; only append relPath to conflicts if the local bytes differ from
the gist bytes. Update the loop that calls gistFilenameToRelPath and
syncPrefForRelPath (and the analogous block around lines 414-425) to perform
this content comparison before treating the file as a conflict.

464-497: ⚠️ Potential issue | 🟠 Major

Avoid leaving a half-restored config on disk.

stageAndWriteGistFiles is still only atomic per file. Once the write loop starts, a later MkdirAll/atomicWriteFile failure returns after earlier files have already been committed, so pull/restore can leave a mixed old/new config set. Please add a rollback path or switch to a temp-tree + final swap strategy so the multi-file operation commits all-or-nothing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 464 - 497, The current
stageAndWriteGistFiles only guarantees atomicity per file and can leave a
partially-updated config if a later write fails; change it to build the full
tree in a temporary directory and then atomically swap it into place.
Specifically: after fetching contents into staged (using gistFilenameToRelPath
and fetchRawContent as currently done), create a temp dir next to baseDir
(os.MkdirTemp/os.CreateTemp), write every staged file into that temp tree using
the same atomicWriteFile helper (preserve relative paths), fsync as needed, then
perform an atomic swap by renaming the existing baseDir out to a backup and
renaming the temp dir to baseDir (os.Rename). On any failure, remove the temp
dir and if you already moved the original, rename the backup back. Keep all
error returns but ensure cleanup paths remove temp and restore backup so the
operation is all-or-nothing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@internal/storage/gist_sync.go`:
- Around line 263-275: The current conflict detection marks any existing local
file as a conflict; instead, read the staged gist content for each gist entry
(use the same map entry referenced by g.Files[name] or the staged content source
used elsewhere in gist_sync.go), read the local file bytes (os.ReadFile /
ioutil.ReadFile) at absPath, and compare byte-for-byte; only append relPath to
conflicts if the local bytes differ from the gist bytes. Update the loop that
calls gistFilenameToRelPath and syncPrefForRelPath (and the analogous block
around lines 414-425) to perform this content comparison before treating the
file as a conflict.
- Around line 464-497: The current stageAndWriteGistFiles only guarantees
atomicity per file and can leave a partially-updated config if a later write
fails; change it to build the full tree in a temporary directory and then
atomically swap it into place. Specifically: after fetching contents into staged
(using gistFilenameToRelPath and fetchRawContent as currently done), create a
temp dir next to baseDir (os.MkdirTemp/os.CreateTemp), write every staged file
into that temp tree using the same atomicWriteFile helper (preserve relative
paths), fsync as needed, then perform an atomic swap by renaming the existing
baseDir out to a backup and renaming the temp dir to baseDir (os.Rename). On any
failure, remove the temp dir and if you already moved the original, rename the
backup back. Keep all error returns but ensure cleanup paths remove temp and
restore backup so the operation is all-or-nothing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 3ceb1925-b0ec-49ea-91fb-7a2d809028d3

📥 Commits

Reviewing files that changed from the base of the PR and between 191bb9b and 95699d0.

📒 Files selected for processing (1)
  • internal/storage/gist_sync.go

@shinokada
Copy link
Owner Author

@coderabbitai Regarding to your Outside diff range comments, the following is the reply:
Suggestion 1 (content-compare conflicts): Decline
The suggestion is structurally incompatible with the current pipeline. Conflict detection is a pre-flight stat check that runs before any gist content is fetched. To compare bytes you'd need to either download gist content twice, or invert the pipeline so all content is fetched before conflict detection — which changes the entire flow order and adds meaningful latency to every restore. The stated motivation (idempotent re-runs after partial success) is already covered by force=true, which the overwrite-warning UI surfaces directly to the user.
The zip path has exactly the same design and would need the same treatment for consistency, making the scope even larger. The cost-to-benefit ratio is poor.
Suggestion 2 (all-or-nothing write): Decline the rollback; improve the comment
This was reviewed in the previous round and the decision stands. A true cross-directory atomic swap is not achievable without OS-specific syscalls, and the partial-write scenario is a recoverable annoyance ("re-run with force"), not data loss (no individual file is ever torn thanks to atomicWriteFile).
The comment was already accurate but has been tightened to make the two-tier atomicity guarantee explicit — per-file is atomic, the full set is not — and to name the recovery path (force=true) directly in the docstring so future readers understand the design is intentional.

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

Tip

For best results, initiate chat on the files or code changes.

@shinokada: Understood on both counts — thank you for the thorough explanations.

  • Suggestion 1: The pre-flight / stat-only pipeline structure makes sense, and the force=true escape hatch is a clean way to cover the idempotent re-run case without doubling HTTP traffic. Happy to drop this.
  • Suggestion 2: The two-tier atomicity guarantee (per-file atomic via atomicWriteFile, full-set not atomic, recovery via force=true) is a pragmatic and well-considered design. Tightening the docstring to make that contract explicit is exactly the right move.

I've recorded both decisions as learnings for future reviews.

(˵ •̀ ᴗ - ˵) ✧


✏️ Learnings added
Learnt from: shinokada
URL: https://github.com/shinokada/tera/pull/119

Timestamp: 2026-03-11T20:01:00.748Z
Learning: In the `tera` project (github.com/shinokada/tera), conflict detection in `ConflictingFilesForGist` and related restore flows (`RestoreFromGistDirect`) is intentionally a pre-flight stat-only check that runs before any gist content is fetched. Suggesting content-byte comparison would require either double-downloading gist content or inverting the pipeline, adding latency. The idempotent re-run scenario is already covered by `force=true`, which the overwrite-warning UI surfaces to the user. The zip restore path follows the same design.

Learnt from: shinokada
URL: https://github.com/shinokada/tera/pull/119

Timestamp: 2026-03-11T20:01:00.748Z
Learning: In the `tera` project (github.com/shinokada/tera), `stageAndWriteGistFiles` and related restore paths use a deliberate two-tier atomicity model: each individual file write is atomic via `atomicWriteFile` (write-to-temp + rename), but the full set of files across multiple directories is not rolled back atomically. A true cross-directory atomic swap would require OS-specific syscalls. A partial write is treated as a recoverable annoyance, not data loss — users can re-run with `force=true`. The docstring explicitly documents this two-tier guarantee and names `force=true` as the recovery path.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/storage/gist_sync.go`:
- Around line 333-349: The gist filename mapping allows different public
filenames to collapse to the same local relPath (e.g., "search-history.json" and
"fav--search-history.json" both map to filepath.Join("data","favorites",
SystemFileSearchHistory)), so modify the mapping logic (in categorizePath and
the code that builds the staged map which keys by relPath) to detect and reject
duplicate mapped destinations up front: when gistFilenameToRelPath(name) returns
a relPath that is already claimed, return an error (or skip and log) instead of
overwriting staged[relPath]; additionally explicitly reserve the fav-- +
SystemFileSearchHistory form by treating any mapping that produces
filepath.Join("data","favorites", SystemFileSearchHistory) as valid only when
the original gist filename has the fav-- prefix (or reject the alias), and apply
the same collision check to the other mapping block mentioned (lines ~481-492)
so no two different gist names can map to the same relPath.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: df755052-092d-4bf2-80b5-388c0a9fd9be

📥 Commits

Reviewing files that changed from the base of the PR and between 95699d0 and 1aec689.

📒 Files selected for processing (1)
  • internal/storage/gist_sync.go

Comment on lines +333 to +349
func categorizePath(name string, prefs *SyncPrefs) {
relPath := gistFilenameToRelPath(name)
switch {
case relPath == "config.yaml":
prefs.Settings = true
case relPath == filepath.Join("data", "favorites", SystemFileSearchHistory):
prefs.SearchHistory = true
case strings.HasPrefix(filepath.ToSlash(relPath), "data/favorites/"):
prefs.Favorites = true
case relPath == filepath.Join("data", "station_ratings.json") ||
relPath == filepath.Join("data", "voted_stations.json"):
prefs.RatingsVotes = true
case relPath == filepath.Join("data", "blocklist.json"):
prefs.Blocklist = true
case relPath == filepath.Join("data", "station_metadata.json") ||
relPath == filepath.Join("data", "station_tags.json"):
prefs.MetadataTags = true
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject filename aliases that collapse onto the same restore path.

The new shared mapping accepts both search-history.json and fav--search-history.json, but both resolve to the same local file. That means a crafted public gist can be categorized incorrectly and, during restore, whichever entry is seen last silently wins because staged is keyed by relPath. Please reject duplicate mapped destinations up front, or explicitly reserve fav-- + SystemFileSearchHistory.

Also applies to: 481-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/storage/gist_sync.go` around lines 333 - 349, The gist filename
mapping allows different public filenames to collapse to the same local relPath
(e.g., "search-history.json" and "fav--search-history.json" both map to
filepath.Join("data","favorites", SystemFileSearchHistory)), so modify the
mapping logic (in categorizePath and the code that builds the staged map which
keys by relPath) to detect and reject duplicate mapped destinations up front:
when gistFilenameToRelPath(name) returns a relPath that is already claimed,
return an error (or skip and log) instead of overwriting staged[relPath];
additionally explicitly reserve the fav-- + SystemFileSearchHistory form by
treating any mapping that produces filepath.Join("data","favorites",
SystemFileSearchHistory) as valid only when the original gist filename has the
fav-- prefix (or reject the alias), and apply the same collision check to the
other mapping block mentioned (lines ~481-492) so no two different gist names
can map to the same relPath.

@shinokada
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 11, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@shinokada shinokada merged commit 6dd8281 into main Mar 12, 2026
9 checks passed
@shinokada shinokada deleted the fix/restore-from-gist branch March 12, 2026 04:58
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.

1 participant