diff --git a/cmd/adopt.go b/cmd/adopt.go index da8c49d..69f00ce 100644 --- a/cmd/adopt.go +++ b/cmd/adopt.go @@ -36,13 +36,13 @@ func runAdopt(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Parent is the required positional argument parent := args[0] diff --git a/cmd/adopt_test.go b/cmd/adopt_test.go index 9f71a80..9781c5e 100644 --- a/cmd/adopt_test.go +++ b/cmd/adopt_test.go @@ -12,8 +12,8 @@ import ( func TestAdoptBranch(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -40,8 +40,8 @@ func TestAdoptBranch(t *testing.T) { func TestAdoptRejectsAlreadyTracked(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -60,8 +60,8 @@ func TestAdoptRejectsAlreadyTracked(t *testing.T) { func TestAdoptRejectsUntrackedParent(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -81,8 +81,8 @@ func TestAdoptRejectsUntrackedParent(t *testing.T) { func TestAdoptDetectsCycle(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -117,8 +117,8 @@ func TestAdoptDetectsCycle(t *testing.T) { func TestAdoptStoresForkPoint(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) diff --git a/cmd/cascade.go b/cmd/cascade.go index e43a139..b19d0c5 100644 --- a/cmd/cascade.go +++ b/cmd/cascade.go @@ -47,13 +47,13 @@ func runCascade(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Check if cascade already in progress if state.Exists(g.GetGitDir()) { return errors.New("operation already in progress; use 'gh stack continue' or 'gh stack abort'") diff --git a/cmd/continue.go b/cmd/continue.go index d1bf035..d98640a 100644 --- a/cmd/continue.go +++ b/cmd/continue.go @@ -63,7 +63,7 @@ func runContinue(cmd *cobra.Command, args []string) error { fmt.Printf("%s Completed %s\n", s.SuccessIcon(), s.Branch(st.Current)) - cfg, err := config.Load(cwd) + cfg, err := config.New(g) if err != nil { return err } diff --git a/cmd/create.go b/cmd/create.go index fa2aa58..08bcdf0 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -39,13 +39,13 @@ func runCreate(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Get current branch currentBranch, err := g.CurrentBranch() if err != nil { diff --git a/cmd/create_test.go b/cmd/create_test.go index ba23992..ead448c 100644 --- a/cmd/create_test.go +++ b/cmd/create_test.go @@ -14,10 +14,9 @@ import ( func TestCreateFromTrunk(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) - cfg.SetTrunk("main") - g := git.New(dir) + cfg, _ := config.New(g) + cfg.SetTrunk("main") // Simulate what create command does currentBranch, _ := g.CurrentBranch() @@ -57,8 +56,8 @@ func TestCreateFromTrunk(t *testing.T) { func TestCreateFromTrackedBranch(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) currentBranch, _ := g.CurrentBranch() cfg.SetTrunk(currentBranch) @@ -86,8 +85,8 @@ func TestCreateFromTrackedBranch(t *testing.T) { func TestCreateRejectsUntrackedBranch(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -119,8 +118,8 @@ func TestCreateRejectsUntrackedBranch(t *testing.T) { func TestCreateWithStagedChanges(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -151,8 +150,8 @@ func TestCreateWithStagedChanges(t *testing.T) { func TestCreateEmptyWithStagedChanges(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -197,8 +196,8 @@ func TestBranchAlreadyExists(t *testing.T) { func TestCreateStoresForkPoint(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) diff --git a/cmd/init.go b/cmd/init.go index 6953430..e766b46 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -32,13 +32,13 @@ func runInit(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Determine trunk branch trunk := trunkFlag if trunk == "" { diff --git a/cmd/init_test.go b/cmd/init_test.go index 6f2b2ff..403fbd2 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" ) func setupTestRepo(t *testing.T) string { @@ -37,9 +38,10 @@ func TestInitCommand(t *testing.T) { dir := setupTestRepo(t) // Verify the config package works for init - cfg, err := config.Load(dir) + g := git.New(dir) + cfg, err := config.New(g) if err != nil { - t.Fatalf("Load failed: %v", err) + t.Fatalf("New failed: %v", err) } err = cfg.SetTrunk("main") diff --git a/cmd/link.go b/cmd/link.go index 2d1228e..1728e8f 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -35,12 +35,12 @@ func runLink(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - - g := git.New(cwd) branch, err := g.CurrentBranch() if err != nil { return err diff --git a/cmd/link_test.go b/cmd/link_test.go index af1ffe1..e2c5ea2 100644 --- a/cmd/link_test.go +++ b/cmd/link_test.go @@ -11,8 +11,8 @@ import ( func TestLinkPR(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -40,8 +40,8 @@ func TestLinkPR(t *testing.T) { func TestLinkRejectsUntrackedBranch(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -59,8 +59,8 @@ func TestLinkRejectsUntrackedBranch(t *testing.T) { func TestLinkOverwritesPR(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) diff --git a/cmd/log.go b/cmd/log.go index d53c163..2c248e1 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -40,7 +40,9 @@ func runLog(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } @@ -49,8 +51,6 @@ func runLog(cmd *cobra.Command, args []string) error { if err != nil { return err } - - g := git.New(cwd) currentBranch, _ := g.CurrentBranch() //nolint:errcheck // empty string is fine for display // Try to get GitHub client for PR URLs (optional - may fail if not in a GitHub repo) diff --git a/cmd/log_test.go b/cmd/log_test.go index b39ea73..e1575a3 100644 --- a/cmd/log_test.go +++ b/cmd/log_test.go @@ -5,13 +5,15 @@ import ( "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/tree" ) func TestLogBuildTree(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) + g := git.New(dir) + cfg, _ := config.New(g) cfg.SetTrunk("main") cfg.SetParent("feature-a", "main") cfg.SetParent("feature-b", "feature-a") @@ -50,7 +52,8 @@ func TestLogBuildTree(t *testing.T) { func TestLogEmptyStack(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) + g := git.New(dir) + cfg, _ := config.New(g) cfg.SetTrunk("main") // No branches tracked, just trunk @@ -70,7 +73,8 @@ func TestLogEmptyStack(t *testing.T) { func TestLogMultipleBranches(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) + g := git.New(dir) + cfg, _ := config.New(g) cfg.SetTrunk("main") // Two branches off main cfg.SetParent("feature-a", "main") diff --git a/cmd/orphan.go b/cmd/orphan.go index 66d5aa9..cf4654d 100644 --- a/cmd/orphan.go +++ b/cmd/orphan.go @@ -33,13 +33,13 @@ func runOrphan(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Determine branch to orphan var branchName string if len(args) > 0 { diff --git a/cmd/orphan_test.go b/cmd/orphan_test.go index 576359b..218a20b 100644 --- a/cmd/orphan_test.go +++ b/cmd/orphan_test.go @@ -12,8 +12,8 @@ import ( func TestOrphanBranch(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -41,8 +41,8 @@ func TestOrphanBranch(t *testing.T) { func TestOrphanRejectsWithChildren(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -66,8 +66,8 @@ func TestOrphanRejectsWithChildren(t *testing.T) { func TestOrphanForceWithDescendants(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -114,8 +114,8 @@ func TestOrphanForceWithDescendants(t *testing.T) { func TestOrphanRemovesPR(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -145,8 +145,8 @@ func TestOrphanRemovesPR(t *testing.T) { func TestOrphanRemovesForkPoint(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) diff --git a/cmd/submit.go b/cmd/submit.go index 843e7f1..f147a51 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -78,13 +78,13 @@ func runSubmit(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - g := git.New(cwd) - // Check if operation already in progress if state.Exists(g.GetGitDir()) { return errors.New("operation already in progress; use 'gh stack continue' or 'gh stack abort'") diff --git a/cmd/sync.go b/cmd/sync.go index 85b2cc3..a8fd23c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -96,7 +96,9 @@ func runSync(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } @@ -106,8 +108,6 @@ func runSync(cmd *cobra.Command, args []string) error { return err } - g := git.New(cwd) - trunk, err := cfg.GetTrunk() if err != nil { return err diff --git a/cmd/undo.go b/cmd/undo.go index 76ac651..7fd50c2 100644 --- a/cmd/undo.go +++ b/cmd/undo.go @@ -124,7 +124,7 @@ func runUndo(cmd *cobra.Command, args []string) error { } // Load config for restoring stack metadata - cfg, err := config.Load(cwd) + cfg, err := config.New(g) if err != nil { return fmt.Errorf("failed to load config: %w", err) } diff --git a/cmd/unlink.go b/cmd/unlink.go index e5e6194..1ed18e6 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -28,12 +28,12 @@ func runUnlink(cmd *cobra.Command, args []string) error { return err } - cfg, err := config.Load(cwd) + g := git.New(cwd) + + cfg, err := config.New(g) if err != nil { return err } - - g := git.New(cwd) branch, err := g.CurrentBranch() if err != nil { return err diff --git a/cmd/unlink_test.go b/cmd/unlink_test.go index c3d23a9..1bad1be 100644 --- a/cmd/unlink_test.go +++ b/cmd/unlink_test.go @@ -11,8 +11,8 @@ import ( func TestUnlinkPR(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) @@ -41,8 +41,8 @@ func TestUnlinkPR(t *testing.T) { func TestUnlinkIdempotent(t *testing.T) { dir := setupTestRepo(t) - cfg, _ := config.Load(dir) g := git.New(dir) + cfg, _ := config.New(g) trunk, _ := g.CurrentBranch() cfg.SetTrunk(trunk) diff --git a/internal/config/config.go b/internal/config/config.go index 4c93686..ac06d0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,12 +3,12 @@ package config import ( "bufio" - "bytes" "errors" - "os/exec" "regexp" "strconv" "strings" + + "github.com/boneskull/gh-stack/internal/git" ) // ErrNotInitialized is returned when stack tracking is not initialized. @@ -25,77 +25,76 @@ var ErrNoForkPoint = errors.New("no fork point stored for branch") // Config provides access to stack metadata stored in .git/config. type Config struct { - repoPath string + g *git.Git } -// Load creates a Config for the repository at the given path. -func Load(repoPath string) (*Config, error) { - if _, err := exec.Command("git", "-C", repoPath, "rev-parse", "--git-dir").Output(); err != nil { +// New creates a Config that routes all git-config calls through the given Git instance. +func New(g *git.Git) (*Config, error) { + if _, err := g.GetResolvedGitDir(); err != nil { return nil, errors.New("not a git repository") } - return &Config{repoPath: repoPath}, nil + return &Config{g: g}, nil } // GetTrunk returns the configured trunk branch name. func (c *Config) GetTrunk() (string, error) { - out, err := exec.Command("git", "-C", c.repoPath, "config", "--get", "stack.trunk").Output() + out, err := c.g.ConfigGet("stack.trunk") if err != nil { return "", ErrNotInitialized } - return strings.TrimSpace(string(out)), nil + return out, nil } // SetTrunk sets the trunk branch name. func (c *Config) SetTrunk(branch string) error { - cmd := exec.Command("git", "-C", c.repoPath, "config", "stack.trunk", branch) - return cmd.Run() + return c.g.ConfigSet("stack.trunk", branch) } // GetParent returns the parent branch for the given branch. func (c *Config) GetParent(branch string) (string, error) { key := "branch." + branch + ".stackParent" - out, err := exec.Command("git", "-C", c.repoPath, "config", "--get", key).Output() + out, err := c.g.ConfigGet(key) if err != nil { return "", ErrBranchNotTracked } - return strings.TrimSpace(string(out)), nil + return out, nil } // SetParent sets the parent branch for the given branch. func (c *Config) SetParent(branch, parent string) error { key := "branch." + branch + ".stackParent" - return exec.Command("git", "-C", c.repoPath, "config", key, parent).Run() + return c.g.ConfigSet(key, parent) } // RemoveParent removes the parent tracking for a branch. func (c *Config) RemoveParent(branch string) error { key := "branch." + branch + ".stackParent" // --unset returns error if key doesn't exist, which is fine - _ = exec.Command("git", "-C", c.repoPath, "config", "--unset", key).Run() //nolint:errcheck // unset returns error if key missing + _ = c.g.ConfigUnset(key) //nolint:errcheck // unset returns error if key missing return nil } // GetPR returns the PR number for the given branch. func (c *Config) GetPR(branch string) (int, error) { key := "branch." + branch + ".stackPR" - out, err := exec.Command("git", "-C", c.repoPath, "config", "--get", key).Output() + out, err := c.g.ConfigGet(key) if err != nil { return 0, ErrNoPR } - return strconv.Atoi(strings.TrimSpace(string(out))) + return strconv.Atoi(out) } // SetPR sets the PR number for the given branch. func (c *Config) SetPR(branch string, pr int) error { key := "branch." + branch + ".stackPR" - return exec.Command("git", "-C", c.repoPath, "config", key, strconv.Itoa(pr)).Run() + return c.g.ConfigSet(key, strconv.Itoa(pr)) } // RemovePR removes the PR association for a branch. func (c *Config) RemovePR(branch string) error { key := "branch." + branch + ".stackPR" // --unset returns error if key doesn't exist, which is fine - _ = exec.Command("git", "-C", c.repoPath, "config", "--unset", key).Run() //nolint:errcheck // unset returns error if key missing + _ = c.g.ConfigUnset(key) //nolint:errcheck // unset returns error if key missing return nil } @@ -103,30 +102,39 @@ func (c *Config) RemovePR(branch string) error { // The fork point is where the branch originally diverged from its parent. func (c *Config) GetForkPoint(branch string) (string, error) { key := "branch." + branch + ".stackForkPoint" - out, err := exec.Command("git", "-C", c.repoPath, "config", "--get", key).Output() + out, err := c.g.ConfigGet(key) if err != nil { return "", ErrNoForkPoint } - return strings.TrimSpace(string(out)), nil + return out, nil } // SetForkPoint stores the fork point SHA for a branch. func (c *Config) SetForkPoint(branch, sha string) error { key := "branch." + branch + ".stackForkPoint" - return exec.Command("git", "-C", c.repoPath, "config", key, sha).Run() + return c.g.ConfigSet(key, sha) +} + +// SetForkPointWithComment stores the fork point SHA with an inline comment. +// Requires Git 2.45+. The comment appears after the value on the same line. +// Returns an error with stderr content on failure, so callers can distinguish +// "unknown option" (old git) from other failures. +func (c *Config) SetForkPointWithComment(branch, sha, comment string) error { + key := "branch." + branch + ".stackForkPoint" + return c.g.ConfigSetWithComment(key, sha, comment) } // RemoveForkPoint removes the stored fork point for a branch. func (c *Config) RemoveForkPoint(branch string) error { key := "branch." + branch + ".stackForkPoint" - _ = exec.Command("git", "-C", c.repoPath, "config", "--unset", key).Run() //nolint:errcheck // unset returns error if key missing + _ = c.g.ConfigUnset(key) //nolint:errcheck // unset returns error if key missing return nil } // ListTrackedBranches returns all branches that have a stackParent set. func (c *Config) ListTrackedBranches() ([]string, error) { // Note: git normalizes config keys to lowercase, so stackParent becomes stackparent - out, err := exec.Command("git", "-C", c.repoPath, "config", "--get-regexp", "^branch\\..*\\.stackparent$").Output() + out, err := c.g.ConfigGetRegexp(`^branch\..*\.stackparent$`) if err != nil { // No matches is not an error — git config exits non-zero when no keys match return []string{}, nil //nolint:nilerr // non-zero exit = no matching config keys @@ -134,7 +142,7 @@ func (c *Config) ListTrackedBranches() ([]string, error) { var branches []string re := regexp.MustCompile(`^branch\.(.+)\.stackparent\s+`) - scanner := bufio.NewScanner(bytes.NewReader(out)) + scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := scanner.Text() if matches := re.FindStringSubmatch(line); len(matches) > 1 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9b395ca..7adf813 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,13 +3,17 @@ package config_test import ( "errors" + "os" "os/exec" + "path/filepath" + "strings" "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" ) -func setupTestRepo(t *testing.T) string { +func setupTestRepo(t *testing.T) *git.Git { t.Helper() dir := t.TempDir() @@ -24,15 +28,15 @@ func setupTestRepo(t *testing.T) string { exec.Command("git", "-C", dir, "config", "user.email", "test@test.com").Run() exec.Command("git", "-C", dir, "config", "user.name", "Test").Run() - return dir + return git.New(dir) } func TestGetTrunk_NotInitialized(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, err := config.Load(dir) + cfg, err := config.New(g) if err != nil { - t.Fatalf("Load failed: %v", err) + t.Fatalf("New failed: %v", err) } _, err = cfg.GetTrunk() @@ -42,11 +46,11 @@ func TestGetTrunk_NotInitialized(t *testing.T) { } func TestSetTrunk(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, err := config.Load(dir) + cfg, err := config.New(g) if err != nil { - t.Fatalf("Load failed: %v", err) + t.Fatalf("New failed: %v", err) } if setErr := cfg.SetTrunk("main"); setErr != nil { @@ -63,9 +67,9 @@ func TestSetTrunk(t *testing.T) { } func TestGetParent_NotTracked(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, _ := config.Load(dir) + cfg, _ := config.New(g) _, err := cfg.GetParent("feature-a") if !errors.Is(err, config.ErrBranchNotTracked) { @@ -74,9 +78,9 @@ func TestGetParent_NotTracked(t *testing.T) { } func TestSetAndGetParent(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, _ := config.Load(dir) + cfg, _ := config.New(g) cfg.SetTrunk("main") if err := cfg.SetParent("feature-a", "main"); err != nil { @@ -93,9 +97,9 @@ func TestSetAndGetParent(t *testing.T) { } func TestPRNumber(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, _ := config.Load(dir) + cfg, _ := config.New(g) // No PR set initially _, err := cfg.GetPR("feature-a") @@ -129,9 +133,9 @@ func TestPRNumber(t *testing.T) { } func TestListTrackedBranches(t *testing.T) { - dir := setupTestRepo(t) + g := setupTestRepo(t) - cfg, _ := config.Load(dir) + cfg, _ := config.New(g) cfg.SetTrunk("main") cfg.SetParent("feature-a", "main") cfg.SetParent("feature-b", "feature-a") @@ -156,8 +160,8 @@ func TestListTrackedBranches(t *testing.T) { } func TestForkPoint(t *testing.T) { - dir := setupTestRepo(t) - cfg, _ := config.Load(dir) + g := setupTestRepo(t) + cfg, _ := config.New(g) // Initially no fork point _, err := cfg.GetForkPoint("feature") @@ -191,3 +195,40 @@ func TestForkPoint(t *testing.T) { t.Errorf("after remove, GetForkPoint = %v, want ErrNoForkPoint", err) } } + +func TestSetForkPointWithComment(t *testing.T) { + g := setupTestRepo(t) + cfg, _ := config.New(g) + + sha := "abc123def456" + comment := "replaces deadbee (2026-02-14T00:00:00Z)" + + if err := cfg.SetForkPointWithComment("feature", sha, comment); err != nil { + // git config --comment requires Git 2.45+; skip on older versions. + t.Skipf("SetForkPointWithComment not supported (git too old?): %v", err) + } + + // Value should be readable via GetForkPoint (git config ignores inline comments) + got, err := cfg.GetForkPoint("feature") + if err != nil { + t.Fatalf("GetForkPoint after SetForkPointWithComment failed: %v", err) + } + if got != sha { + t.Errorf("GetForkPoint = %q, want %q", got, sha) + } + + // The raw config file should contain the inline comment + gitDir, err := g.GetResolvedGitDir() + if err != nil { + t.Fatalf("failed to resolve git dir: %v", err) + } + configPath := filepath.Join(gitDir, "config") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read .git/config: %v", err) + } + configStr := string(data) + if !strings.Contains(configStr, "# "+comment) { + t.Errorf("expected inline comment in config, got:\n%s", configStr) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index c71232e..c3c8388 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -525,6 +525,63 @@ func (g *Git) GetCommits(base, head string) ([]Commit, error) { return commits, nil } +// IsAncestor returns true if ancestor is an ancestor of descendant. +// Both arguments must be valid commit references. +func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) { + err := g.runSilent("merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // Exit status 1 means "not ancestor" when both commits exist. + if exitErr.ExitCode() == 1 { + return false, nil + } + } + + // Propagate other failures (e.g., invalid refs, repo errors). + return false, err +} + +// GetForkPoint returns the reflog-aware fork point of branch from parent. +// This uses `git merge-base --fork-point`, which inspects the reflog to find +// where the branch originally diverged. If the underlying Git command fails +// for any reason, an error is returned and callers may fall back to GetMergeBase. +func (g *Git) GetForkPoint(parent, branch string) (string, error) { + return g.run("merge-base", "--fork-point", parent, branch) +} + +// ConfigGet returns the value of a git config key. +// Returns an error if the key does not exist. +func (g *Git) ConfigGet(key string) (string, error) { + return g.run("config", "--get", key) +} + +// ConfigSet sets a git config key to a value. +func (g *Git) ConfigSet(key, value string) error { + return g.runSilent("config", key, value) +} + +// ConfigSetWithComment sets a git config key to a value with an inline comment. +// Requires Git 2.45+. Returns an error with stderr content on failure, so +// callers can distinguish "unknown option" (old git) from other failures. +func (g *Git) ConfigSetWithComment(key, value, comment string) error { + return g.runSilent("config", "--comment", comment, key, value) +} + +// ConfigUnset removes a git config key. Returns an error if the key does not exist. +func (g *Git) ConfigUnset(key string) error { + return g.runSilent("config", "--unset", key) +} + +// ConfigGetRegexp returns the raw output of `git config --get-regexp`. +// Returns an error if no keys match. +func (g *Git) ConfigGetRegexp(pattern string) (string, error) { + return g.run("config", "--get-regexp", pattern) +} + // AbbrevSHA safely abbreviates a SHA to 7 characters. // Returns the full string if it's shorter than 7 characters. func AbbrevSHA(sha string) string { diff --git a/internal/tree/tree_test.go b/internal/tree/tree_test.go index 2c9be2a..7c2c6f1 100644 --- a/internal/tree/tree_test.go +++ b/internal/tree/tree_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/boneskull/gh-stack/internal/config" + "github.com/boneskull/gh-stack/internal/git" "github.com/boneskull/gh-stack/internal/tree" ) @@ -17,7 +18,8 @@ func setupTestRepo(t *testing.T) (*config.Config, string) { exec.Command("git", "-C", dir, "config", "user.email", "test@test.com").Run() exec.Command("git", "-C", dir, "config", "user.name", "Test").Run() - cfg, _ := config.Load(dir) + g := git.New(dir) + cfg, _ := config.New(g) return cfg, dir }