harden trail worktree checkout edge cases · Entire

harden trail worktree checkout edge cases

93556b5→main·

pfleidi·1w ago·3 files·+97 added/-24 removed

Address final-review findings: fail early with a clear error when the trail's branch is already checked out in a non-managed worktree (previously a raw git fatal after the ignore rule was written), confine .worktreeinclude copies to the new worktree via os.OpenRoot so tracked symlinked directories cannot redirect writes, bound the branch fetch with the same 2-minute timeout as other remote operations, and document the extra --force meaning in the flag help.

Sessions

01KX4QZZKB1JWMA2FYET5ZK1CCView transcript

[?
Add trail checkout --worktree SupportClaude Code·1 step](/content/gh/entireio/cli/session/c5215fe7-28db-4a83-9d2e-482217df292d#timeline-01KX4QZZKB1JWMA2FYET5ZK1CC/index.html)

Changes

3

10 unmodified lines

11
12
13
14
15
16
17
158 unmodified lines

176
177
178
178
179
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
73 unmodified lines

269
270
271
259
272
273
274
275
276
277
278
13 unmodified lines

292
293
294
279
295
296
297
282
298
299
300
301
302
303
288
289
304
305
306
307
292
293
308
309
310
295
296
297
311
312
313
314
315
316
6 unmodified lines

323
324
325
310
326
327
328
312
329
330
331
332
333
334
335
336
35 unmodified lines

372
373
374
354
355
356
375
376
377
378
379
380
359
381
382
383
384
385
386
387
366
388
389
390
391
8 unmodified lines

400
401
402
381
382
383
403
404
405
406
386
407
408
409
410
37 unmodified lines

448
449
450
451
452
453
454
455
456

10 unmodified lines

"os/exec"
    "path/filepath"
    "strings"
    "time"

huh "charm.land/huh/v2"
    "github.com/go-git/go-git/v6/plumbing/format/gitignore"
158 unmodified lines

if err != nil {
        return err
    }
    for _, rel := range matchIncludePatterns(patterns, ignored) {
        if err := copyIncludedFile(filepath.Join(root, rel), filepath.Join(dest, rel)); err != nil {
            ...
        }
    }

return clean, true
}

func copyIncludedFile(src, dst string) error {
    ...
}

func TestCopyWorktreeIncludeFiles_RefusesSymlinkedDirEscape(t *testing.T) {
    testutil.IsolateGitConfigEnv(t)

repoDir := t.TempDir()
    testutil.InitRepo(t, repoDir)
    testutil.WriteFile(t, repoDir, ".gitignore", "sub/"+testEnvFile+"\n")
    testutil.WriteFile(t, repoDir, ".worktreeinclude", "sub/"+testEnvFile+"\n")
    testutil.WriteFile(t, repoDir, "sub/"+testEnvFile, "SECRET=1\n")

dest := t.TempDir()
    outside := t.TempDir()
    if err := os.Symlink(outside, filepath.Join(dest, "sub")); err != nil {
        t.Skipf("symlinks unsupported: %v", err)
    }

var errOut bytes.Buffer
    if err := copyWorktreeIncludeFiles(context.Background(), &errOut, repoDir, dest); err != nil {
        t.Fatalf("copyWorktreeIncludeFiles: %v", err)
    }
    if !strings.Contains(errOut.String(), "warning: skipped sub/"+testEnvFile) {
        t.Fatalf("stderr = %q, want skip warning for sub/%s", errOut.String(), testEnvFile)
    }
    if _, err := os.Stat(filepath.Join(outside, testEnvFile)); !os.IsNotExist(err) {
        t.Fatalf("%s stat in outside dir = %v, want not exist", testEnvFile, err)
    }
}

func newTrailWorktreeTestRepo(t *testing.T) string {
    ...
}

func TestCheckoutTrailWorktree_BranchCheckedOutInMainWorktree(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    startBranch := currentBranchInDir(t, repoDir)
    t.Chdir(repoDir)

var out, errOut bytes.Buffer
    err := checkoutTrailWorktree(context.Background(), &out, &errOut, startBranch, false, 1)
    if err == nil || !strings.Contains(err.Error(), "already checked out at") {
        t.Fatalf("error = %v, want already-checked-out error", err)
    }

if _, statErr := os.Stat(filepath.Join(repoDir, ".entire", "worktrees")); !os.IsNotExist(statErr) {
        t.Fatalf(".entire/worktrees stat = %v, want not exist", statErr)
    }
    if _, statErr := os.Stat(filepath.Join(repoDir, ".git", "info", "exclude")); statErr == nil {
        content, readErr := os.ReadFile(filepath.Join(repoDir, ".git", "info", "exclude"))
        if readErr == nil && strings.Contains(string(content), ".entire/worktrees/") {
            t.Fatalf("exclude gained the rule despite failing before the ignore-rule write")
        }
    }
}

func TestCheckoutTrailWorktree_ReusesExistingWorktree(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    runTrailWorktreeGit(t, repoDir, "branch", "feature/reuse")

Mcmd/entire/cli/trail_checkout_worktree_test.go+49

1261 unmodified lines

1262
1263
1264
1265
1265
1266
1267
1268

1261 unmodified lines

}

cmd.Flags().StringVar(&trailSelector, "trail", "", "Trail to check out (number, id, or branch; defaults to the current branch's trail)")
    cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip the prompt before fetching a remote-only branch")
    cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip the prompt before fetching a remote-only branch (with --worktree, also skip the ignore-rule prompt)")
    cmd.Flags().BoolVar(&worktree, "worktree", false, "Check out the trail branch in a worktree under .entire/worktrees instead of switching this checkout")

return cmd

Mcmd/entire/cli/trail_cmd.go+1/-1