diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 5f3600a..33056b6 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -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" ) @@ -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", @@ -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 @@ -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") } diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go index 8dd9d54..0e765b9 100644 --- a/internal/vcs/vcs.go +++ b/internal/vcs/vcs.go @@ -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 diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go index 9cc5022..7f1ffa7 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -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) { @@ -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) + } +} diff --git a/tests/integration/symlink_test.go b/tests/integration/symlink_test.go index cf6856e..f387ff5 100644 --- a/tests/integration/symlink_test.go +++ b/tests/integration/symlink_test.go @@ -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) } } diff --git a/tests/integration/which_test.go b/tests/integration/which_test.go index 5239e67..2396576 100644 --- a/tests/integration/which_test.go +++ b/tests/integration/which_test.go @@ -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) } }