Skip to content

Commit 01bcd47

Browse files
fix(skills): stage updates in a temp dir and swap in-place (cli#13449)
Co-authored-by: sammorrowdrums <sammorrowdrums@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 733e35c commit 01bcd47

3 files changed

Lines changed: 242 additions & 52 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Updating a namespaced skill via --dir must write back to its original
2+
# location and must NOT delete the original directory (issue #13370).
3+
4+
# Dry-run update should detect the namespaced skill and report an update
5+
exec gh skill update --dry-run --all --dir $WORK/skills-dir
6+
stdout 'git-commit'
7+
8+
# Force update should re-download in-place
9+
exec gh skill update --force --all --dir $WORK/skills-dir
10+
stdout 'Updated'
11+
12+
# Verify the SKILL.md was rewritten at the ORIGINAL namespaced location
13+
grep 'github-repo' $WORK/skills-dir/anthropics-skills/git-commit/SKILL.md
14+
! grep 'Test skill content' $WORK/skills-dir/anthropics-skills/git-commit/SKILL.md
15+
16+
# The namespace directory must still exist (not deleted)
17+
exists $WORK/skills-dir/anthropics-skills/git-commit/SKILL.md
18+
19+
# The skill must NOT have been relocated to a flat path
20+
! exists $WORK/skills-dir/git-commit/SKILL.md
21+
22+
-- skills-dir/anthropics-skills/git-commit/SKILL.md --
23+
---
24+
name: git-commit
25+
description: Git commit helper
26+
metadata:
27+
github-repo: https://github.com/github/awesome-copilot.git
28+
github-tree-sha: 0000000000000000000000000000000000000000
29+
github-path: skills/git-commit
30+
---
31+
Test skill content

pkg/cmd/skills/update/update.go

Lines changed: 117 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -385,54 +385,11 @@ func updateRun(opts *UpdateOptions) error {
385385

386386
var failed bool
387387
for _, u := range updates {
388-
installOpts := &installer.Options{
389-
Host: u.local.repoHost,
390-
Owner: u.local.owner,
391-
Repo: u.local.repo,
392-
Ref: u.resolved.Ref,
393-
SHA: u.resolved.SHA,
394-
Skills: []discovery.Skill{u.skill},
395-
AgentHost: u.local.host,
396-
Scope: u.local.scope,
397-
GitRoot: gitRoot,
398-
HomeDir: homeDir,
399-
Client: apiClient,
400-
}
401-
// When updating skills from a custom --dir, host is nil.
402-
// Use the skill's install root as the target. For namespaced
403-
// skills (name contains "/"), the dir is two levels below the
404-
// root instead of one.
405-
if u.local.host == nil {
406-
base := filepath.Dir(u.local.dir)
407-
if strings.Contains(u.local.name, "/") {
408-
base = filepath.Dir(base)
409-
}
410-
installOpts.Dir = base
411-
}
412-
_, installErr := installer.Install(installOpts)
413-
if installErr != nil {
414-
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, installErr)
388+
if err := updateSkillInPlace(opts, u, apiClient, gitRoot, homeDir); err != nil {
389+
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err)
415390
failed = true
416391
continue
417392
}
418-
419-
// When the install location has changed (e.g. migrating from a
420-
// namespaced layout to flat), remove the old directory so that the
421-
// stale copy does not shadow the freshly installed one.
422-
newDir := filepath.Join(installOpts.Dir, u.skill.Name)
423-
if installOpts.Dir == "" && u.local.host != nil {
424-
if d, err := u.local.host.InstallDir(u.local.scope, gitRoot, homeDir); err == nil {
425-
newDir = filepath.Join(d, u.skill.Name)
426-
}
427-
}
428-
if newDir != "" && u.local.dir != "" && filepath.Clean(newDir) != filepath.Clean(u.local.dir) {
429-
_ = os.RemoveAll(u.local.dir)
430-
// Remove the parent if it is now empty (leftover namespace directory).
431-
parent := filepath.Dir(u.local.dir)
432-
if entries, readErr := os.ReadDir(parent); readErr == nil && len(entries) == 0 {
433-
_ = os.Remove(parent)
434-
}
435-
}
436393
if opts.IO.IsStdoutTTY() {
437394
fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name)
438395
} else {
@@ -447,6 +404,121 @@ func updateRun(opts *UpdateOptions) error {
447404
return nil
448405
}
449406

407+
// updateSkillInPlace installs the resolved update into a staging directory
408+
// alongside the existing skill directory and, on success, atomically swaps
409+
// the staged contents into place via same-filesystem renames. This
410+
// guarantees:
411+
//
412+
// - The skill directory's own inode is preserved, so symlinks, mounts, and
413+
// external references that point at it stay valid.
414+
// - Stale files from the previous version are removed.
415+
// - A failure at any point (install, read, rename) leaves the existing
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
466+
// into a sibling backup directory, then moves the staged entries into dest.
467+
// If any step fails, the original contents are restored from the backup.
468+
//
469+
// src and dest must live on the same filesystem so renames are atomic.
470+
func swapDirectoryContents(dest, src string) error {
471+
backup, err := os.MkdirTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".gh-skill-backup-")
472+
if err != nil {
473+
return fmt.Errorf("could not create backup directory: %w", err)
474+
}
475+
476+
existing, err := os.ReadDir(dest)
477+
if err != nil {
478+
_ = os.RemoveAll(backup)
479+
return fmt.Errorf("could not read skill directory %s: %w", dest, err)
480+
}
481+
var movedOut []string
482+
for _, entry := range existing {
483+
if err := os.Rename(filepath.Join(dest, entry.Name()), filepath.Join(backup, entry.Name())); err != nil {
484+
restoreBackup(dest, backup, movedOut, nil)
485+
return fmt.Errorf("could not move %s aside: %w", entry.Name(), err)
486+
}
487+
movedOut = append(movedOut, entry.Name())
488+
}
489+
490+
staged, err := os.ReadDir(src)
491+
if err != nil {
492+
restoreBackup(dest, backup, movedOut, nil)
493+
return fmt.Errorf("could not read staged skill directory %s: %w", src, err)
494+
}
495+
var movedIn []string
496+
for _, entry := range staged {
497+
from := filepath.Join(src, entry.Name())
498+
to := filepath.Join(dest, entry.Name())
499+
if err := os.Rename(from, to); err != nil {
500+
restoreBackup(dest, backup, movedOut, movedIn)
501+
return fmt.Errorf("could not move %s into place: %w", entry.Name(), err)
502+
}
503+
movedIn = append(movedIn, entry.Name())
504+
}
505+
506+
_ = os.RemoveAll(backup)
507+
return nil
508+
}
509+
510+
// restoreBackup undoes a partial swap by removing any freshly installed
511+
// entries and moving the original entries back from backup into dest.
512+
func restoreBackup(dest, backup string, movedOut, movedIn []string) {
513+
for _, name := range movedIn {
514+
_ = os.RemoveAll(filepath.Join(dest, name))
515+
}
516+
for _, name := range movedOut {
517+
_ = os.Rename(filepath.Join(backup, name), filepath.Join(dest, name))
518+
}
519+
_ = os.RemoveAll(backup)
520+
}
521+
450522
// scanAllAgents walks every registered agent's skill directory (project + user scope) and
451523
// collects installed skills. Shared install roots are scanned only once.
452524
func scanAllAgents(gitRoot, homeDir string) []installedSkill {

pkg/cmd/skills/update/update_test.go

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ func TestUpdateRun(t *testing.T) {
709709
wantStdout: "Updated code-review",
710710
},
711711
{
712-
name: "namespaced skill with --dir resolves install base correctly",
712+
name: "namespaced skill with --dir updates in-place",
713713
setup: func(t *testing.T, dir string) {
714714
t.Helper()
715715
homeDir := t.TempDir()
@@ -727,6 +727,8 @@ func TestUpdateRun(t *testing.T) {
727727
---
728728
Old namespaced content
729729
`)), 0o644))
730+
// Plant a stale file that should be cleaned during update.
731+
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "STALE.txt"), []byte("leftover"), 0o644))
730732
},
731733
stubs: func(reg *httpmock.Registry) {
732734
reg.Register(
@@ -762,14 +764,20 @@ func TestUpdateRun(t *testing.T) {
762764
},
763765
verify: func(t *testing.T, dir string) {
764766
t.Helper()
765-
// After update, skill should be installed flat (not namespaced).
766-
content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md"))
767+
// Skill must stay in its original namespaced directory.
768+
content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md"))
767769
require.NoError(t, err)
768770
assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills")
769771
assert.NotContains(t, string(content), "Old namespaced content")
770-
// Old namespaced directory should be cleaned up.
772+
// Skill must NOT have been relocated to a flat path.
773+
_, err = os.Stat(filepath.Join(dir, "code-review", "SKILL.md"))
774+
assert.True(t, os.IsNotExist(err), "skill should not be relocated to flat path")
775+
// Namespace directory must still exist.
771776
_, err = os.Stat(filepath.Join(dir, "monalisa", "code-review"))
772-
assert.True(t, os.IsNotExist(err), "old namespaced directory should be removed")
777+
assert.False(t, os.IsNotExist(err), "namespaced directory must not be deleted")
778+
// Stale file should have been cleaned during update.
779+
_, err = os.Stat(filepath.Join(dir, "monalisa", "code-review", "STALE.txt"))
780+
assert.True(t, os.IsNotExist(err), "stale file should be removed during update")
773781
},
774782
wantStdout: "Updated monalisa/code-review",
775783
},
@@ -1219,9 +1227,9 @@ func TestUpdateRun(t *testing.T) {
12191227

12201228
if tt.wantErr != "" {
12211229
assert.EqualError(t, err, tt.wantErr)
1222-
return
1230+
} else {
1231+
require.NoError(t, err)
12231232
}
1224-
require.NoError(t, err)
12251233
if tt.wantStderr != "" {
12261234
assert.Contains(t, stderr.String(), tt.wantStderr)
12271235
}
@@ -1234,3 +1242,82 @@ func TestUpdateRun(t *testing.T) {
12341242
})
12351243
}
12361244
}
1245+
1246+
// If the staged contents cannot be installed after the existing entries
1247+
// have already been moved aside, the original skill directory must be
1248+
// restored byte-for-byte and its inode must be preserved.
1249+
func TestSwapDirectoryContents_RollsBackOnFailure(t *testing.T) {
1250+
parent := t.TempDir()
1251+
dest := filepath.Join(parent, "code-review")
1252+
require.NoError(t, os.MkdirAll(dest, 0o755))
1253+
require.NoError(t, os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte("original"), 0o644))
1254+
require.NoError(t, os.WriteFile(filepath.Join(dest, "extra.txt"), []byte("keep me"), 0o644))
1255+
subdir := filepath.Join(dest, "examples")
1256+
require.NoError(t, os.MkdirAll(subdir, 0o755))
1257+
require.NoError(t, os.WriteFile(filepath.Join(subdir, "demo.txt"), []byte("demo"), 0o644))
1258+
1259+
destBefore, err := os.Stat(dest)
1260+
require.NoError(t, err)
1261+
1262+
// Point src at a path that does not exist so the staged ReadDir fails
1263+
// after the existing entries have already been moved aside. This is the
1264+
// only deterministic, portable way to exercise the rollback branch from
1265+
// outside the swap.
1266+
src := filepath.Join(parent, "does-not-exist")
1267+
1268+
err = swapDirectoryContents(dest, src)
1269+
require.Error(t, err, "swap should fail when staged dir cannot be read")
1270+
1271+
destAfter, err := os.Stat(dest)
1272+
require.NoError(t, err)
1273+
assert.True(t, os.SameFile(destBefore, destAfter), "dest directory identity must be preserved across rollback")
1274+
1275+
content, readErr := os.ReadFile(filepath.Join(dest, "SKILL.md"))
1276+
require.NoError(t, readErr)
1277+
assert.Equal(t, "original", string(content), "original SKILL.md must be restored")
1278+
extra, readErr := os.ReadFile(filepath.Join(dest, "extra.txt"))
1279+
require.NoError(t, readErr)
1280+
assert.Equal(t, "keep me", string(extra), "original extra.txt must be restored")
1281+
demo, readErr := os.ReadFile(filepath.Join(subdir, "demo.txt"))
1282+
require.NoError(t, readErr)
1283+
assert.Equal(t, "demo", string(demo), "original nested subdir must be restored intact")
1284+
1285+
entries, err := os.ReadDir(parent)
1286+
require.NoError(t, err)
1287+
var leftovers []string
1288+
for _, e := range entries {
1289+
if e.Name() != "code-review" {
1290+
leftovers = append(leftovers, e.Name())
1291+
}
1292+
}
1293+
assert.Empty(t, leftovers, "no staging or backup directories should remain after rollback")
1294+
}
1295+
1296+
// The skill directory's own inode must survive an update so symlinks,
1297+
// bind mounts, and other external references pointing at it remain
1298+
// valid. Per-entry rename swaps satisfy this; replacing the directory
1299+
// itself would not.
1300+
func TestSwapDirectoryContents_PreservesDestInode(t *testing.T) {
1301+
parent := t.TempDir()
1302+
dest := filepath.Join(parent, "code-review")
1303+
require.NoError(t, os.MkdirAll(dest, 0o755))
1304+
require.NoError(t, os.WriteFile(filepath.Join(dest, "old.txt"), []byte("old"), 0o644))
1305+
1306+
src := filepath.Join(parent, "staged")
1307+
require.NoError(t, os.MkdirAll(src, 0o755))
1308+
require.NoError(t, os.WriteFile(filepath.Join(src, "new.txt"), []byte("new"), 0o644))
1309+
1310+
destBefore, err := os.Stat(dest)
1311+
require.NoError(t, err)
1312+
1313+
require.NoError(t, swapDirectoryContents(dest, src))
1314+
1315+
destAfter, err := os.Stat(dest)
1316+
require.NoError(t, err)
1317+
assert.True(t, os.SameFile(destBefore, destAfter), "dest directory identity must be preserved")
1318+
1319+
assert.NoFileExists(t, filepath.Join(dest, "old.txt"), "stale files must be removed")
1320+
content, err := os.ReadFile(filepath.Join(dest, "new.txt"))
1321+
require.NoError(t, err)
1322+
assert.Equal(t, "new", string(content), "staged content must be installed")
1323+
}

0 commit comments

Comments
 (0)