Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions cmd/arc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
cfgpkg "github.com/sentiolabs/arc/internal/config"
"github.com/sentiolabs/arc/internal/project"
"github.com/sentiolabs/arc/internal/types"
"github.com/sentiolabs/arc/internal/vcs"
"github.com/sentiolabs/arc/internal/version"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -302,6 +303,15 @@ func init() {
projectCmd.AddCommand(projectDeleteCmd)
}

// whichResult is the JSON shape returned by the which command.
type whichResult struct {
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name,omitempty"`
Source string `json:"source"`
VCS []string `json:"vcs"` // always present; [] when none
Warning string `json:"warning,omitempty"`
}

// whichCmd shows the active project and how it was resolved.
var whichCmd = &cobra.Command{
Use: "which",
Expand All @@ -328,16 +338,16 @@ This helps debug project resolution issues by showing:
}
}

cwd, _ := os.Getwd()
systems := vcs.Detect(cwd)

if outputJSON {
result := map[string]string{
"project_id": wsID,
"source": source.String(),
}
if wsName != "" {
result["project_name"] = wsName
}
if warning != "" {
result["warning"] = warning
result := whichResult{
ProjectID: wsID,
ProjectName: wsName,
Source: source.String(),
VCS: systems,
Warning: warning,
}
outputResult(result)
return nil
Expand All @@ -351,6 +361,10 @@ This helps debug project resolution issues by showing:
}
fmt.Printf("Source: %s\n", source)

if len(systems) > 0 {
fmt.Printf("VCS: %s\n", strings.Join(systems, ", "))
}

if source == ProjectSourceProject {
fmt.Printf("Config: legacy ~/.arc/projects/ config\n")
}
Expand Down
16 changes: 16 additions & 0 deletions internal/vcs/vcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ import (
"github.com/sentiolabs/arc/internal/jjfs"
)

// Detect reports which version-control systems are present at or above dir,
// in stable order ("git" before "jj"). A colocated jj/git repo returns both;
// native jj returns only "jj"; a plain git repo returns only "git". Returns
// an empty (non-nil) slice when neither is found. Pure filesystem walk — no
// git or jj binary is invoked.
func Detect(dir string) []string {
systems := []string{}
if gitfs.FindGitEntry(dir) != "" {
systems = append(systems, "git")
}
if jjfs.FindJJEntry(dir) != "" {
systems = append(systems, "jj")
}
return systems
}

// DetectMainRepo returns the canonical main-repo working directory if dir is
// inside a linked git worktree or a secondary jj workspace; otherwise "".
// git is tried first, jj is the fallback. The result is canonicalized via
Expand Down
61 changes: 59 additions & 2 deletions internal/vcs/vcs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (
// These verify the design spec. Do NOT modify without updating the approved plan.

var (
_ func(string) string = vcs.DetectMainRepo
_ func(string) string = vcs.DetectRemote
_ func(string) string = vcs.DetectMainRepo
_ func(string) string = vcs.DetectRemote
_ func(string) []string = vcs.Detect
)

func TestVCSContract(t *testing.T) {
Expand Down Expand Up @@ -105,3 +106,59 @@ func TestDetectMainRepo_NotInWorktree(t *testing.T) {
t.Fatalf("DetectMainRepo(plain dir) = %q, want \"\"", got)
}
}

// --- vcs.Detect tests ---

func makeJJEntry(t *testing.T, dir string) {
t.Helper()
// Simulate a .jj directory with the minimal structure FindJJEntry looks for.
jjDir := filepath.Join(dir, ".jj")
if err := os.MkdirAll(jjDir, 0o755); err != nil {
t.Fatal(err)
}
}

func TestDetect_PlainGit(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")

got := vcs.Detect(dir)
if len(got) != 1 || got[0] != "git" {
t.Fatalf("Detect(plain git) = %v, want [git]", got)
}
}

func TestDetect_NativeJJ(t *testing.T) {
dir := t.TempDir()
makeJJEntry(t, dir)
// No .git directory — native jj only.

got := vcs.Detect(dir)
if len(got) != 1 || got[0] != "jj" {
t.Fatalf("Detect(native jj) = %v, want [jj]", got)
}
}

func TestDetect_Colocated(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
makeJJEntry(t, dir)

got := vcs.Detect(dir)
if len(got) != 2 || got[0] != "git" || got[1] != "jj" {
t.Fatalf("Detect(colocated) = %v, want [git jj]", got)
}
}

func TestDetect_Neither(t *testing.T) {
dir := t.TempDir()

got := vcs.Detect(dir)
// Must be non-nil and empty so JSON marshals to [] not null.
if got == nil {
t.Fatal("Detect(no repo) returned nil, want empty non-nil slice")
}
if len(got) != 0 {
t.Fatalf("Detect(no repo) = %v, want []", got)
}
}
15 changes: 11 additions & 4 deletions tests/integration/symlink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,24 +122,31 @@ func TestWhichFromSymlinkAndRealPath(t *testing.T) {

arcCmdInDirSuccess(t, home, symlinkDir, "init", "symlink-which-proj", "--server", serverURL)

// whichResult captures the fields this test asserts on. Decoding into a
// struct (rather than map[string]string) tolerates non-string fields such
// as the vcs array in `which --json` output.
type whichResult struct {
ProjectID string `json:"project_id"`
}

// `arc which` from symlink.
whichSymlink := arcCmdInDirSuccess(t, home, symlinkDir, "which", "--json", "--server", serverURL)
var resultSymlink map[string]string
var resultSymlink whichResult
if err := json.Unmarshal([]byte(whichSymlink), &resultSymlink); err != nil {
t.Fatalf("parse which JSON from symlink: %v\noutput: %s", err, whichSymlink)
}

// `arc which` from real path.
whichReal := arcCmdInDirSuccess(t, home, realDir, "which", "--json", "--server", serverURL)
var resultReal map[string]string
var resultReal whichResult
if err := json.Unmarshal([]byte(whichReal), &resultReal); err != nil {
t.Fatalf("parse which JSON from real: %v\noutput: %s", err, whichReal)
}

// Both should resolve to the same project.
if resultSymlink["project_id"] != resultReal["project_id"] {
if resultSymlink.ProjectID != resultReal.ProjectID {
t.Errorf("project IDs differ: symlink=%q, real=%q",
resultSymlink["project_id"], resultReal["project_id"])
resultSymlink.ProjectID, resultReal.ProjectID)
}
}

Expand Down
20 changes: 15 additions & 5 deletions tests/integration/which_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,29 @@ func TestWhichJsonOutput(t *testing.T) {

out := arcCmdInDirSuccess(t, home, dir, "which", "--json", "--server", serverURL)

var result map[string]string
var result struct {
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
Source string `json:"source"`
VCS []string `json:"vcs"`
}
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("expected valid JSON from which --json, got parse error: %v\noutput: %s", err, out)
}

if result["project_id"] == "" {
if result.ProjectID == "" {
t.Error("expected non-empty project_id in JSON output")
}
if result["source"] == "" {
if result.Source == "" {
t.Error("expected non-empty source in JSON output")
}
if result["project_name"] != "which-json-proj" {
t.Errorf("expected project_name 'which-json-proj', got %q", result["project_name"])
if result.ProjectName != "which-json-proj" {
t.Errorf("expected project_name 'which-json-proj', got %q", result.ProjectName)
}
// The vcs field is always present (an array, never null). This temp dir is
// not a git/jj repo, so it is empty — but it must decode to a non-nil slice.
if result.VCS == nil {
t.Errorf("expected vcs field to be present (non-null array) in JSON output, got: %s", out)
}
}

Expand Down
Loading