This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+55
View File
@@ -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
}
+148
View File
@@ -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)
}
}
+109
View File
@@ -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
}
+204
View File
@@ -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")
}
}
+156
View File
@@ -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)
}
+245
View File
@@ -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)
}
}
+49
View File
@@ -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)))
}
+95
View File
@@ -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)
}
})
}
}
+72
View File
@@ -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
}
+145
View File
@@ -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")
}
}