@@ -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