11
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HasChanges reports whether the working tree at workspaceRoot has any
|
||||
// uncommitted changes (staged, unstaged, or untracked).
|
||||
func HasChanges(ctx context.Context, workspaceRoot string) (bool, error) {
|
||||
out, _, err := runGit(ctx, workspaceRoot, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
// MergePreviewOpts carries options for MergePreview.
|
||||
type MergePreviewOpts struct {
|
||||
WorkspaceRoot string
|
||||
Branch string // branch to preview-merge into HEAD
|
||||
}
|
||||
|
||||
// MergePreviewResult holds the outcome of a preview merge attempt.
|
||||
// When Clean is false, Conflicts lists the raw CONFLICT lines from git output.
|
||||
// Faithful to the TS implementation: the repo is left in merge-in-progress
|
||||
// state so the caller can inspect it; run `git merge --abort` to undo.
|
||||
type MergePreviewResult struct {
|
||||
Clean bool
|
||||
Conflicts []string
|
||||
Output string
|
||||
}
|
||||
|
||||
// MergePreview runs git merge --no-commit --no-ff <Branch> in WorkspaceRoot.
|
||||
// On success (no conflicts) Clean is true. On conflict, Clean is false and
|
||||
// Conflicts lists the CONFLICT lines from git output.
|
||||
func MergePreview(ctx context.Context, opts MergePreviewOpts) (MergePreviewResult, error) {
|
||||
stdout, stderr, err := runGit(ctx, opts.WorkspaceRoot, "merge", "--no-commit", "--no-ff", opts.Branch)
|
||||
combined := strings.TrimSpace(stdout + "\n" + stderr)
|
||||
if err == nil {
|
||||
return MergePreviewResult{Clean: true, Output: combined}, nil
|
||||
}
|
||||
var conflicts []string
|
||||
for _, line := range strings.Split(combined, "\n") {
|
||||
// git outputs "CONFLICT" in English or "冲突" in localized builds.
|
||||
if strings.HasPrefix(line, "CONFLICT") || strings.Contains(line, "冲突") {
|
||||
conflicts = append(conflicts, line)
|
||||
}
|
||||
}
|
||||
return MergePreviewResult{
|
||||
Clean: false,
|
||||
Conflicts: conflicts,
|
||||
Output: combined,
|
||||
}, err
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/git"
|
||||
)
|
||||
|
||||
// initTestRepo creates a temporary git repo with one commit on the default branch.
|
||||
func initTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init")
|
||||
run("config", "user.email", "test@test.com")
|
||||
run("config", "user.name", "test")
|
||||
f := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestHasChanges_Clean(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
has, err := git.HasChanges(context.Background(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if has {
|
||||
t.Error("expected no changes on clean repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasChanges_Dirty(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
f := filepath.Join(dir, "new.txt")
|
||||
if err := os.WriteFile(f, []byte("dirty"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has, err := git.HasChanges(context.Background(), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !has {
|
||||
t.Error("expected changes with untracked file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePreview_Clean(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
// Create a branch with a non-conflicting new file.
|
||||
run("checkout", "-b", "feature")
|
||||
f := filepath.Join(dir, "feature.txt")
|
||||
if err := os.WriteFile(f, []byte("feature"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "feature commit")
|
||||
// Return to the original branch via checkout -.
|
||||
run("checkout", "-")
|
||||
|
||||
result, err := git.MergePreview(context.Background(), git.MergePreviewOpts{
|
||||
WorkspaceRoot: dir,
|
||||
Branch: "feature",
|
||||
})
|
||||
// A fast-forward-prevented merge that succeeds may leave MERGE_HEAD; abort to clean up.
|
||||
defer func() {
|
||||
cmd := exec.Command("git", "-C", dir, "merge", "--abort")
|
||||
_ = cmd.Run()
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected clean merge, got error: %v (output: %s)", err, result.Output)
|
||||
}
|
||||
if !result.Clean {
|
||||
t.Errorf("expected Clean=true, got false; conflicts: %v", result.Conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePreview_Conflict(t *testing.T) {
|
||||
dir := initTestRepo(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
// Create a branch that modifies file.txt.
|
||||
run("checkout", "-b", "conflict-branch")
|
||||
f := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(f, []byte("branch change"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "branch change")
|
||||
|
||||
// Return to original branch and make a conflicting change to the same file.
|
||||
run("checkout", "-")
|
||||
if err := os.WriteFile(f, []byte("main change"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "main change")
|
||||
|
||||
result, _ := git.MergePreview(context.Background(), git.MergePreviewOpts{
|
||||
WorkspaceRoot: dir,
|
||||
Branch: "conflict-branch",
|
||||
})
|
||||
// Abort the in-progress merge to leave repo clean for test teardown.
|
||||
defer func() {
|
||||
cmd := exec.Command("git", "-C", dir, "merge", "--abort")
|
||||
_ = cmd.Run()
|
||||
}()
|
||||
|
||||
if result.Clean {
|
||||
t.Error("expected Clean=false for conflicting merge")
|
||||
}
|
||||
if len(result.Conflicts) == 0 {
|
||||
t.Errorf("expected at least one conflict entry, got none; output: %s", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BranchSyncStatus struct {
|
||||
Behind int
|
||||
DevBranch string
|
||||
AutoBuildBranch string
|
||||
Unavailable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
type BranchSyncOpts struct {
|
||||
WorkspaceRoot string
|
||||
DevBranch string
|
||||
AutoBuildBranch string
|
||||
}
|
||||
|
||||
// runGit runs git -C workspaceRoot <args> and returns stdout, stderr, error.
|
||||
func runGit(ctx context.Context, workspaceRoot string, args ...string) (string, string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", workspaceRoot}, args...)...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err
|
||||
}
|
||||
|
||||
// CheckBranchSync returns the sync status between devBranch and autoBuildBranch.
|
||||
func CheckBranchSync(ctx context.Context, opts BranchSyncOpts) (BranchSyncStatus, error) {
|
||||
if opts.DevBranch == opts.AutoBuildBranch {
|
||||
return BranchSyncStatus{
|
||||
Unavailable: true,
|
||||
Reason: "开发分支与自动化构建分支相同",
|
||||
}, nil
|
||||
}
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if _, _, err := runGit(checkCtx, opts.WorkspaceRoot, "rev-parse", "--is-inside-work-tree"); err != nil {
|
||||
return BranchSyncStatus{Unavailable: true, Reason: fmt.Sprintf("非 git 仓库: %v", err)}, nil
|
||||
}
|
||||
|
||||
fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer fetchCancel()
|
||||
if _, stderr, err := runGit(fetchCtx, opts.WorkspaceRoot, "fetch", "origin", opts.DevBranch, opts.AutoBuildBranch); err != nil {
|
||||
return BranchSyncStatus{Unavailable: true, Reason: fmt.Sprintf("fetch 失败: %s", stderr)}, nil
|
||||
}
|
||||
|
||||
revRange := fmt.Sprintf("origin/%s..origin/%s", opts.AutoBuildBranch, opts.DevBranch)
|
||||
out, _, err := runGit(ctx, opts.WorkspaceRoot, "rev-list", "--count", revRange)
|
||||
if err != nil {
|
||||
return BranchSyncStatus{Unavailable: true, Reason: fmt.Sprintf("rev-list 失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
behind, err := strconv.Atoi(out)
|
||||
if err != nil {
|
||||
return BranchSyncStatus{Unavailable: true, Reason: fmt.Sprintf("解析提交数失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
return BranchSyncStatus{
|
||||
Behind: behind,
|
||||
DevBranch: opts.DevBranch,
|
||||
AutoBuildBranch: opts.AutoBuildBranch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RunBranchSync pushes devBranch HEAD to autoBuildBranch (no --force).
|
||||
func RunBranchSync(ctx context.Context, opts BranchSyncOpts) error {
|
||||
fetchCtx, fetchCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer fetchCancel()
|
||||
if _, stderr, err := runGit(fetchCtx, opts.WorkspaceRoot, "fetch", "origin", opts.DevBranch, opts.AutoBuildBranch); err != nil {
|
||||
return fmt.Errorf("fetch 失败: %s: %w", stderr, err)
|
||||
}
|
||||
|
||||
refspec := fmt.Sprintf("origin/%s:refs/heads/%s", opts.DevBranch, opts.AutoBuildBranch)
|
||||
if _, stderr, err := runGit(ctx, opts.WorkspaceRoot, "push", "origin", refspec); err != nil {
|
||||
return fmt.Errorf("push 失败: %s: %w", stderr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GitFetch runs git fetch origin in workspaceRoot, best-effort.
|
||||
func GitFetch(ctx context.Context, workspaceRoot string) error {
|
||||
_, _, err := runGit(ctx, workspaceRoot, "fetch", "origin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteLocalBranch deletes a local branch if it exists.
|
||||
func DeleteLocalBranch(ctx context.Context, workspaceRoot, branch string) error {
|
||||
_, _, err := runGit(ctx, workspaceRoot, "show-ref", "--verify", "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
// Branch does not exist; no-op.
|
||||
return nil
|
||||
}
|
||||
_, stderr, err := runGit(ctx, workspaceRoot, "branch", "-D", branch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete branch %s: %s: %w", branch, stderr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/git"
|
||||
)
|
||||
|
||||
// initBareAndClone creates a bare origin repo and a local clone with one commit.
|
||||
func initBareAndClone(t *testing.T) (origin, clone string) {
|
||||
t.Helper()
|
||||
origin = t.TempDir()
|
||||
clone = t.TempDir()
|
||||
|
||||
run := func(dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
run(origin, "init", "--bare")
|
||||
|
||||
run(clone, "init")
|
||||
run(clone, "config", "user.email", "test@test.com")
|
||||
run(clone, "config", "user.name", "test")
|
||||
run(clone, "remote", "add", "origin", origin)
|
||||
|
||||
f := filepath.Join(clone, "README")
|
||||
if err := os.WriteFile(f, []byte("init"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run(clone, "add", ".")
|
||||
run(clone, "commit", "-m", "init")
|
||||
run(clone, "push", "origin", "HEAD:refs/heads/main")
|
||||
run(clone, "push", "origin", "HEAD:refs/heads/build")
|
||||
|
||||
return origin, clone
|
||||
}
|
||||
|
||||
func TestCheckBranchSync_SameBranch_Unavailable(t *testing.T) {
|
||||
_, clone := initBareAndClone(t)
|
||||
status, err := git.CheckBranchSync(context.Background(), git.BranchSyncOpts{
|
||||
WorkspaceRoot: clone,
|
||||
DevBranch: "main",
|
||||
AutoBuildBranch: "main",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Unavailable {
|
||||
t.Error("same branch should be Unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBranchSync_NotGitRepo_Unavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
status, err := git.CheckBranchSync(context.Background(), git.BranchSyncOpts{
|
||||
WorkspaceRoot: dir,
|
||||
DevBranch: "main",
|
||||
AutoBuildBranch: "build",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.Unavailable {
|
||||
t.Error("non-git-repo should be Unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBranchSync_ZeroBehind(t *testing.T) {
|
||||
_, clone := initBareAndClone(t)
|
||||
status, err := git.CheckBranchSync(context.Background(), git.BranchSyncOpts{
|
||||
WorkspaceRoot: clone,
|
||||
DevBranch: "main",
|
||||
AutoBuildBranch: "build",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Unavailable {
|
||||
t.Errorf("should be available, reason: %s", status.Reason)
|
||||
}
|
||||
if status.Behind != 0 {
|
||||
t.Errorf("Behind = %d, want 0 (branches are in sync)", status.Behind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckBranchSync_NonZeroBehind(t *testing.T) {
|
||||
_, clone := initBareAndClone(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = clone
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
// Advance main by two commits; build stays behind.
|
||||
for i := 0; i < 2; i++ {
|
||||
f := filepath.Join(clone, "f")
|
||||
if err := os.WriteFile(f, []byte{byte(i)}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "advance")
|
||||
}
|
||||
run("push", "origin", "HEAD:refs/heads/main")
|
||||
|
||||
status, err := git.CheckBranchSync(context.Background(), git.BranchSyncOpts{
|
||||
WorkspaceRoot: clone,
|
||||
DevBranch: "main",
|
||||
AutoBuildBranch: "build",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.Unavailable {
|
||||
t.Fatalf("should be available, reason: %s", status.Reason)
|
||||
}
|
||||
if status.Behind != 2 {
|
||||
t.Errorf("Behind = %d, want 2", status.Behind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBranchSync_AdvancesAutoBuild(t *testing.T) {
|
||||
origin, clone := initBareAndClone(t)
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = clone
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
f := filepath.Join(clone, "f")
|
||||
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "advance")
|
||||
run("push", "origin", "HEAD:refs/heads/main")
|
||||
|
||||
revOf := func(ref string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", "-C", origin, "rev-parse", ref)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("rev-parse %s: %v\n%s", ref, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
if revOf("main") == revOf("build") {
|
||||
t.Fatal("precondition: build should be behind main")
|
||||
}
|
||||
|
||||
if err := git.RunBranchSync(context.Background(), git.BranchSyncOpts{
|
||||
WorkspaceRoot: clone,
|
||||
DevBranch: "main",
|
||||
AutoBuildBranch: "build",
|
||||
}); err != nil {
|
||||
t.Fatalf("RunBranchSync: %v", err)
|
||||
}
|
||||
|
||||
if revOf("main") != revOf("build") {
|
||||
t.Errorf("build should have advanced to main; main=%q build=%q", revOf("main"), revOf("build"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLocalBranch_NonExistent_NoOp(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
err := git.DeleteLocalBranch(context.Background(), root, "nonexistent-branch")
|
||||
if err != nil {
|
||||
t.Errorf("DeleteLocalBranch on nonexistent branch should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteLocalBranch_Exists_Deletes(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
run := func(args ...string) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("branch", "temp-branch")
|
||||
err := git.DeleteLocalBranch(context.Background(), root, "temp-branch")
|
||||
if err != nil {
|
||||
t.Errorf("DeleteLocalBranch: %v", err)
|
||||
}
|
||||
// Verify branch is gone.
|
||||
cmd := exec.Command("git", "-C", root, "show-ref", "--verify", "refs/heads/temp-branch")
|
||||
if cmd.Run() == nil {
|
||||
t.Error("branch should have been deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const hookTimeout = 30 * time.Second
|
||||
|
||||
const (
|
||||
scriptPostCreate = ".spx/worktree-post-create.sh"
|
||||
scriptPreRemove = ".spx/worktree-pre-remove.sh"
|
||||
scriptImplTabPreCreate = ".spx/impl-tab-pre-create.sh"
|
||||
scriptImplTabPostClose = ".spx/impl-tab-post-close.sh"
|
||||
)
|
||||
|
||||
// HookStatus is the outcome of a lifecycle hook execution.
|
||||
type HookStatus string
|
||||
|
||||
const (
|
||||
HookStatusSkipped HookStatus = "skipped"
|
||||
HookStatusOk HookStatus = "ok"
|
||||
HookStatusFailed HookStatus = "failed"
|
||||
HookStatusTimeout HookStatus = "timeout"
|
||||
HookStatusEnoent HookStatus = "enoent"
|
||||
)
|
||||
|
||||
// HookContext carries the parameters injected into hook scripts as env vars.
|
||||
type HookContext struct {
|
||||
WorkspaceRoot string
|
||||
WorktreePath string
|
||||
Branch string
|
||||
IssueNumber int
|
||||
MainBranch string
|
||||
// CustomScriptPath overrides the default .spx/<name>.sh location.
|
||||
// Relative paths are resolved against WorkspaceRoot.
|
||||
CustomScriptPath string
|
||||
}
|
||||
|
||||
// HookResult is returned by every hook function regardless of outcome.
|
||||
// Hooks never return a fatal error — failures are captured here.
|
||||
type HookResult struct {
|
||||
Status HookStatus
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
ErrorMessage string
|
||||
ScriptPath string
|
||||
}
|
||||
|
||||
func resolveScript(ctx HookContext, defaultRel string) string {
|
||||
custom := ctx.CustomScriptPath
|
||||
if custom != "" {
|
||||
if filepath.IsAbs(custom) {
|
||||
return custom
|
||||
}
|
||||
return filepath.Join(ctx.WorkspaceRoot, custom)
|
||||
}
|
||||
return filepath.Join(ctx.WorkspaceRoot, defaultRel)
|
||||
}
|
||||
|
||||
func runHook(ctx context.Context, hctx HookContext, defaultRel string) HookResult {
|
||||
scriptPath := resolveScript(hctx, defaultRel)
|
||||
|
||||
if _, err := os.Stat(scriptPath); errors.Is(err, os.ErrNotExist) {
|
||||
return HookResult{Status: HookStatusSkipped, ScriptPath: scriptPath}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, hookTimeout)
|
||||
defer cancel()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(runCtx, "bash", scriptPath)
|
||||
cmd.Dir = hctx.WorktreePath
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"WORKTREE_PATH="+hctx.WorktreePath,
|
||||
"WORKSPACE_ROOT="+hctx.WorkspaceRoot,
|
||||
"BRANCH="+hctx.Branch,
|
||||
"ISSUE_NUMBER="+strconv.Itoa(hctx.IssueNumber),
|
||||
"MAIN_BRANCH="+hctx.MainBranch,
|
||||
)
|
||||
|
||||
err := cmd.Run()
|
||||
stdoutStr := stdout.String()
|
||||
stderrStr := stderr.String()
|
||||
|
||||
if err == nil {
|
||||
return HookResult{
|
||||
Status: HookStatusOk,
|
||||
ExitCode: 0,
|
||||
Stdout: stdoutStr,
|
||||
Stderr: stderrStr,
|
||||
ScriptPath: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Context deadline exceeded means our 30s timeout fired.
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return HookResult{
|
||||
Status: HookStatusTimeout,
|
||||
ErrorMessage: "hook script timed out (30s)",
|
||||
Stdout: stdoutStr,
|
||||
Stderr: stderrStr,
|
||||
ScriptPath: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
// bash binary not found on PATH.
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return HookResult{
|
||||
Status: HookStatusEnoent,
|
||||
ErrorMessage: err.Error(),
|
||||
Stdout: stdoutStr,
|
||||
Stderr: stderrStr,
|
||||
ScriptPath: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
return HookResult{
|
||||
Status: HookStatusFailed,
|
||||
ExitCode: exitErr.ExitCode(),
|
||||
ErrorMessage: err.Error(),
|
||||
Stdout: stdoutStr,
|
||||
Stderr: stderrStr,
|
||||
ScriptPath: scriptPath,
|
||||
}
|
||||
}
|
||||
|
||||
// RunPostCreateHook runs .spx/worktree-post-create.sh after a worktree is created.
|
||||
func RunPostCreateHook(ctx context.Context, hctx HookContext) HookResult {
|
||||
return runHook(ctx, hctx, scriptPostCreate)
|
||||
}
|
||||
|
||||
// RunPreRemoveHook runs .spx/worktree-pre-remove.sh before a worktree is removed.
|
||||
func RunPreRemoveHook(ctx context.Context, hctx HookContext) HookResult {
|
||||
return runHook(ctx, hctx, scriptPreRemove)
|
||||
}
|
||||
|
||||
// RunImplTabPreCreateHook runs .spx/impl-tab-pre-create.sh before an impl CC tab opens.
|
||||
func RunImplTabPreCreateHook(ctx context.Context, hctx HookContext) HookResult {
|
||||
return runHook(ctx, hctx, scriptImplTabPreCreate)
|
||||
}
|
||||
|
||||
// RunImplTabPostCloseHook runs .spx/impl-tab-post-close.sh after an impl CC tab closes.
|
||||
func RunImplTabPostCloseHook(ctx context.Context, hctx HookContext) HookResult {
|
||||
return runHook(ctx, hctx, scriptImplTabPostClose)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/git"
|
||||
)
|
||||
|
||||
func makeHookCtx(workspaceRoot, worktreePath, branch string, issue int, main string) git.HookContext {
|
||||
return git.HookContext{
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
WorktreePath: worktreePath,
|
||||
Branch: branch,
|
||||
IssueNumber: issue,
|
||||
MainBranch: main,
|
||||
}
|
||||
}
|
||||
|
||||
func writeScript(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// setupWorkspace returns workspaceRoot with .spx/ created.
|
||||
func setupWorkspace(t *testing.T) (workspaceRoot, spxDir string) {
|
||||
t.Helper()
|
||||
workspaceRoot = t.TempDir()
|
||||
spxDir = filepath.Join(workspaceRoot, ".spx")
|
||||
if err := os.MkdirAll(spxDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- RunPostCreateHook ---
|
||||
|
||||
func TestRunPostCreateHook_Missing(t *testing.T) {
|
||||
root, _ := setupWorkspace(t)
|
||||
ctx := makeHookCtx(root, root, "feat/x", 42, "main")
|
||||
// No script on disk → skipped.
|
||||
result := git.RunPostCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusSkipped {
|
||||
t.Errorf("want skipped, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPostCreateHook_Ok(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
worktree := t.TempDir()
|
||||
|
||||
// Script writes env vars to a file so we can assert them.
|
||||
envFile := filepath.Join(root, "env-dump.txt")
|
||||
script := `#!/bin/bash
|
||||
echo "WORKTREE_PATH=$WORKTREE_PATH" >> "` + envFile + `"
|
||||
echo "WORKSPACE_ROOT=$WORKSPACE_ROOT" >> "` + envFile + `"
|
||||
echo "BRANCH=$BRANCH" >> "` + envFile + `"
|
||||
echo "ISSUE_NUMBER=$ISSUE_NUMBER" >> "` + envFile + `"
|
||||
echo "MAIN_BRANCH=$MAIN_BRANCH" >> "` + envFile + `"
|
||||
exit 0
|
||||
`
|
||||
writeScript(t, spxDir, "worktree-post-create.sh", script)
|
||||
|
||||
ctx := makeHookCtx(root, worktree, "feat/my-branch", 99, "develop")
|
||||
result := git.RunPostCreateHook(context.Background(), ctx)
|
||||
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Fatalf("want ok, got %v (err=%s stderr=%s)", result.Status, result.ErrorMessage, result.Stderr)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
t.Errorf("want exit code 0, got %d", result.ExitCode)
|
||||
}
|
||||
if result.ScriptPath == "" {
|
||||
t.Error("ScriptPath must not be empty")
|
||||
}
|
||||
|
||||
envData, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatalf("env dump not written: %v", err)
|
||||
}
|
||||
env := string(envData)
|
||||
checks := map[string]string{
|
||||
"WORKTREE_PATH": worktree,
|
||||
"WORKSPACE_ROOT": root,
|
||||
"BRANCH": "feat/my-branch",
|
||||
"ISSUE_NUMBER": "99",
|
||||
"MAIN_BRANCH": "develop",
|
||||
}
|
||||
for k, v := range checks {
|
||||
if !strings.Contains(env, k+"="+v) {
|
||||
t.Errorf("env missing %s=%s; got:\n%s", k, v, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPostCreateHook_Failed(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
writeScript(t, spxDir, "worktree-post-create.sh", "#!/bin/bash\nexit 3\n")
|
||||
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunPostCreateHook(context.Background(), ctx)
|
||||
|
||||
if result.Status != git.HookStatusFailed {
|
||||
t.Errorf("want failed, got %v", result.Status)
|
||||
}
|
||||
if result.ExitCode != 3 {
|
||||
t.Errorf("want ExitCode=3, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- RunPreRemoveHook ---
|
||||
|
||||
func TestRunPreRemoveHook_Missing(t *testing.T) {
|
||||
root, _ := setupWorkspace(t)
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunPreRemoveHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusSkipped {
|
||||
t.Errorf("want skipped, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreRemoveHook_Ok(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
writeScript(t, spxDir, "worktree-pre-remove.sh", "#!/bin/bash\nexit 0\n")
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunPreRemoveHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Errorf("want ok, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- RunImplTabPreCreateHook ---
|
||||
|
||||
func TestRunImplTabPreCreateHook_Missing(t *testing.T) {
|
||||
root, _ := setupWorkspace(t)
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunImplTabPreCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusSkipped {
|
||||
t.Errorf("want skipped, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunImplTabPreCreateHook_Ok(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
writeScript(t, spxDir, "impl-tab-pre-create.sh", "#!/bin/bash\nexit 0\n")
|
||||
ctx := makeHookCtx(root, root, "feat/x", 7, "main")
|
||||
result := git.RunImplTabPreCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Errorf("want ok, got %v", result.Status)
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
t.Errorf("want exit 0, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunImplTabPreCreateHook_Failed(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
writeScript(t, spxDir, "impl-tab-pre-create.sh", "#!/bin/bash\nexit 7\n")
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunImplTabPreCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusFailed {
|
||||
t.Errorf("want failed, got %v", result.Status)
|
||||
}
|
||||
if result.ExitCode != 7 {
|
||||
t.Errorf("want ExitCode=7, got %d", result.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- RunImplTabPostCloseHook ---
|
||||
|
||||
func TestRunImplTabPostCloseHook_Missing(t *testing.T) {
|
||||
root, _ := setupWorkspace(t)
|
||||
ctx := makeHookCtx(root, root, "feat/x", 1, "main")
|
||||
result := git.RunImplTabPostCloseHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusSkipped {
|
||||
t.Errorf("want skipped, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunImplTabPostCloseHook_Ok(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
writeScript(t, spxDir, "impl-tab-post-close.sh", "#!/bin/bash\nexit 0\n")
|
||||
ctx := makeHookCtx(root, root, "feat/x", 3, "main")
|
||||
result := git.RunImplTabPostCloseHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Errorf("want ok, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CustomScriptPath override ---
|
||||
|
||||
func TestRunPostCreateHook_CustomScriptPath(t *testing.T) {
|
||||
root, _ := setupWorkspace(t)
|
||||
customDir := t.TempDir()
|
||||
customScript := filepath.Join(customDir, "my-hook.sh")
|
||||
if err := os.WriteFile(customScript, []byte("#!/bin/bash\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := git.HookContext{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: root,
|
||||
Branch: "feat/x",
|
||||
IssueNumber: 1,
|
||||
MainBranch: "main",
|
||||
CustomScriptPath: customScript, // absolute override
|
||||
}
|
||||
result := git.RunPostCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Errorf("want ok with custom path, got %v", result.Status)
|
||||
}
|
||||
if result.ScriptPath != customScript {
|
||||
t.Errorf("ScriptPath: want %q, got %q", customScript, result.ScriptPath)
|
||||
}
|
||||
}
|
||||
|
||||
// IssueNumber env value must be the decimal string, not anything else.
|
||||
func TestRunPostCreateHook_IssueNumberEnv(t *testing.T) {
|
||||
root, spxDir := setupWorkspace(t)
|
||||
envFile := filepath.Join(root, "issue-num.txt")
|
||||
script := "#!/bin/bash\necho -n \"$ISSUE_NUMBER\" > \"" + envFile + "\"\nexit 0\n"
|
||||
writeScript(t, spxDir, "worktree-post-create.sh", script)
|
||||
|
||||
ctx := makeHookCtx(root, root, "feat/x", 12345, "main")
|
||||
result := git.RunPostCreateHook(context.Background(), ctx)
|
||||
if result.Status != git.HookStatusOk {
|
||||
t.Fatalf("want ok, got %v", result.Status)
|
||||
}
|
||||
data, err := os.ReadFile(envFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.TrimSpace(string(data))
|
||||
if got != strconv.Itoa(12345) {
|
||||
t.Errorf("ISSUE_NUMBER env: want %q, got %q", "12345", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Remote holds the parsed coordinates of a git remote.
|
||||
type Remote struct {
|
||||
Host string
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
|
||||
var (
|
||||
httpsRE = regexp.MustCompile(`^https?://(?:[^@]+@)?([^/:]+)(?::\d+)?/([^/]+)/([^/]+?)(?:\.git)?/?$`)
|
||||
sshRE = regexp.MustCompile(`^(?:ssh://)?(?:[^@]+@)?([^/:]+)[:/]([^/]+)/([^/]+?)(?:\.git)?/?$`)
|
||||
)
|
||||
|
||||
// ParseRemoteURL parses a git remote URL into its Host, Owner, and Repo parts.
|
||||
// Supports https, ssh (git@host:owner/repo), and ssh:// forms.
|
||||
func ParseRemoteURL(url string) (*Remote, error) {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("empty remote URL")
|
||||
}
|
||||
|
||||
if m := httpsRE.FindStringSubmatch(trimmed); m != nil {
|
||||
return &Remote{Host: m[1], Owner: m[2], Repo: m[3]}, nil
|
||||
}
|
||||
|
||||
if m := sshRE.FindStringSubmatch(trimmed); m != nil {
|
||||
return &Remote{Host: m[1], Owner: m[2], Repo: m[3]}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unrecognized remote URL format: %q", trimmed)
|
||||
}
|
||||
|
||||
// DetectRepo resolves the origin remote URL for the given workspace root
|
||||
// and parses it into a Remote.
|
||||
func DetectRepo(workspaceRoot string) (*Remote, error) {
|
||||
out, err := exec.Command("git", "-C", workspaceRoot, "remote", "get-url", "origin").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git remote get-url origin: %w", err)
|
||||
}
|
||||
return ParseRemoteURL(strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/git"
|
||||
)
|
||||
|
||||
func TestParseRemoteURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantHost string
|
||||
wantOwner string
|
||||
wantRepo string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "https with .git",
|
||||
url: "https://gitea.example.com/owner/repo.git",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "https without .git",
|
||||
url: "https://gitea.example.com/owner/repo",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "https with trailing slash",
|
||||
url: "https://gitea.example.com/owner/repo.git/",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "ssh git@ form",
|
||||
url: "git@gitea.example.com:owner/repo.git",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "ssh:// form",
|
||||
url: "ssh://git@gitea.example.com/owner/repo.git",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "ssh git@ without .git",
|
||||
url: "git@gitea.example.com:owner/repo",
|
||||
wantHost: "gitea.example.com",
|
||||
wantOwner: "owner",
|
||||
wantRepo: "repo",
|
||||
},
|
||||
{
|
||||
name: "empty URL",
|
||||
url: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
url: "not-a-url",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := git.ParseRemoteURL(tt.url)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Host != tt.wantHost {
|
||||
t.Errorf("Host: got %q, want %q", got.Host, tt.wantHost)
|
||||
}
|
||||
if got.Owner != tt.wantOwner {
|
||||
t.Errorf("Owner: got %q, want %q", got.Owner, tt.wantOwner)
|
||||
}
|
||||
if got.Repo != tt.wantRepo {
|
||||
t.Errorf("Repo: got %q, want %q", got.Repo, tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
const worktreeTimeout = 30 * time.Second
|
||||
|
||||
// CreateWorktreeOpts holds the parameters for CreateWorktree.
|
||||
type CreateWorktreeOpts struct {
|
||||
WorkspaceRoot string
|
||||
WorktreePath string
|
||||
Branch string
|
||||
}
|
||||
|
||||
// RemoveWorktreeOpts holds the parameters for RemoveWorktree.
|
||||
type RemoveWorktreeOpts struct {
|
||||
WorkspaceRoot string
|
||||
WorktreePath string
|
||||
// Force maps to git worktree remove --force. Use when the worktree may
|
||||
// have uncommitted changes that should be discarded.
|
||||
Force bool
|
||||
}
|
||||
|
||||
// CreateWorktree runs:
|
||||
//
|
||||
// git -C <WorkspaceRoot> worktree add <WorktreePath> -b <Branch>
|
||||
//
|
||||
// A non-zero exit wraps stderr in the returned error.
|
||||
func CreateWorktree(ctx context.Context, opts CreateWorktreeOpts) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, worktreeTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"-C", opts.WorkspaceRoot, "worktree", "add", opts.WorktreePath, "-b", opts.Branch}
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git worktree add failed: %w; stderr: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveWorktree runs:
|
||||
//
|
||||
// git -C <WorkspaceRoot> worktree remove [--force] <WorktreePath>
|
||||
//
|
||||
// A non-zero exit wraps stderr in the returned error.
|
||||
func RemoveWorktree(ctx context.Context, opts RemoveWorktreeOpts) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, worktreeTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"-C", opts.WorkspaceRoot, "worktree", "remove"}
|
||||
if opts.Force {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
args = append(args, opts.WorktreePath)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("git worktree remove failed: %w; stderr: %s", err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package git_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"superwork-tui/internal/git"
|
||||
)
|
||||
|
||||
// initRepo creates a bare git repo with one commit so worktree operations work.
|
||||
func initRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
run := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
run("init")
|
||||
run("config", "user.email", "test@test.com")
|
||||
run("config", "user.name", "test")
|
||||
// Need at least one commit for worktree add -b to work.
|
||||
f := filepath.Join(dir, "README")
|
||||
if err := os.WriteFile(f, []byte("init"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run("add", ".")
|
||||
run("commit", "-m", "init")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestCreateWorktree(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
wtPath := filepath.Join(t.TempDir(), "feature-wt")
|
||||
|
||||
err := git.CreateWorktree(context.Background(), git.CreateWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Branch: "feature/test-branch",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree: %v", err)
|
||||
}
|
||||
|
||||
// Directory must exist.
|
||||
if _, err := os.Stat(wtPath); err != nil {
|
||||
t.Fatalf("worktree dir not created: %v", err)
|
||||
}
|
||||
|
||||
// The new branch must exist in the main repo.
|
||||
out, err := exec.Command("git", "-C", root, "branch", "--list", "feature/test-branch").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git branch --list: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "feature/test-branch") {
|
||||
t.Errorf("branch not created; git branch output: %q", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_BadBranch(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
wtPath := filepath.Join(t.TempDir(), "bad-wt")
|
||||
|
||||
// Using a branch name with ".." is invalid for git.
|
||||
err := git.CreateWorktree(context.Background(), git.CreateWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Branch: "bad..branch",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid branch name, got nil")
|
||||
}
|
||||
// Error must mention something useful (stderr wrapped in).
|
||||
t.Logf("got expected error: %v", err)
|
||||
}
|
||||
|
||||
func TestRemoveWorktree(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
wtPath := filepath.Join(t.TempDir(), "rm-wt")
|
||||
|
||||
if err := git.CreateWorktree(context.Background(), git.CreateWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Branch: "feature/rm-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("setup CreateWorktree: %v", err)
|
||||
}
|
||||
|
||||
err := git.RemoveWorktree(context.Background(), git.RemoveWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Force: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveWorktree: %v", err)
|
||||
}
|
||||
|
||||
// Directory must be gone.
|
||||
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||
t.Errorf("worktree dir still exists after remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorktree_Force(t *testing.T) {
|
||||
root := initRepo(t)
|
||||
wtPath := filepath.Join(t.TempDir(), "force-wt")
|
||||
|
||||
if err := git.CreateWorktree(context.Background(), git.CreateWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Branch: "feature/force-test",
|
||||
}); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
// Write an uncommitted change to make the worktree "dirty".
|
||||
if err := os.WriteFile(filepath.Join(wtPath, "dirty"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("git", "add", ".")
|
||||
cmd.Dir = wtPath
|
||||
cmd.Run() //nolint — best effort for test setup
|
||||
|
||||
// Without force this might fail; with force it must succeed.
|
||||
err := git.RemoveWorktree(context.Background(), git.RemoveWorktreeOpts{
|
||||
WorkspaceRoot: root,
|
||||
WorktreePath: wtPath,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveWorktree --force: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(wtPath); !os.IsNotExist(err) {
|
||||
t.Errorf("worktree dir still exists after force remove")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user