updateSkillInPlace installs the resolved update into a staging directory alongside the existing skill directory and, on success, atomically swaps the staged contents into place via same-filesystem renames. This guarantees: - The skill directory's own inode is preserved, so symlinks, mounts, and ext
(opts *UpdateOptions, u pendingUpdate, apiClient *api.Client, gitRoot, homeDir string)
| 416 | // skill completely untouched: existing files are first moved aside into |
| 417 | // a backup directory and restored if any subsequent step fails. |
| 418 | func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Client, gitRoot, homeDir string) error { |
| 419 | if u.local.dir == "" { |
| 420 | return fmt.Errorf("cannot update %s: no install location recorded", u.local.name) |
| 421 | } |
| 422 | |
| 423 | parent := filepath.Dir(u.local.dir) |
| 424 | if err := os.MkdirAll(parent, 0o755); err != nil { |
| 425 | return fmt.Errorf("could not ensure parent directory %s: %w", parent, err) |
| 426 | } |
| 427 | |
| 428 | // Stage as a sibling of the existing skill directory so the swap stays |
| 429 | // on the same filesystem and every rename is atomic. |
| 430 | staging, err := os.MkdirTemp(parent, "."+u.skill.Name+".gh-skill-update-") |
| 431 | if err != nil { |
| 432 | return fmt.Errorf("could not create staging directory: %w", err) |
| 433 | } |
| 434 | defer os.RemoveAll(staging) |
| 435 | |
| 436 | installOpts := &installer.Options{ |
| 437 | Host: u.local.repoHost, |
| 438 | Owner: u.local.owner, |
| 439 | Repo: u.local.repo, |
| 440 | Ref: u.resolved.Ref, |
| 441 | SHA: u.resolved.SHA, |
| 442 | Skills: []discovery.Skill{u.skill}, |
| 443 | Dir: staging, |
| 444 | GitRoot: gitRoot, |
| 445 | HomeDir: homeDir, |
| 446 | Client: apiClient, |
| 447 | } |
| 448 | if _, err := installer.Install(installOpts); err != nil { |
| 449 | return err |
| 450 | } |
| 451 | |
| 452 | stagedSkillDir := filepath.Join(staging, u.skill.Name) |
| 453 | if _, err := os.Stat(stagedSkillDir); err != nil { |
| 454 | return fmt.Errorf("installer did not produce %s: %w", stagedSkillDir, err) |
| 455 | } |
| 456 | |
| 457 | if err := os.MkdirAll(u.local.dir, 0o755); err != nil { |
| 458 | return fmt.Errorf("could not ensure skill directory %s: %w", u.local.dir, err) |
| 459 | } |
| 460 | |
| 461 | return swapDirectoryContents(u.local.dir, stagedSkillDir) |
| 462 | } |
| 463 | |
| 464 | // swapDirectoryContents replaces the entries inside dest with the entries |
| 465 | // inside src, preserving dest's inode. It first moves every existing entry |
no test coverage detected