diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb5310d..7d8846e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,24 @@ jobs: - name: Download dependencies run: go mod download + - name: Install jj + run: | + JJ_VERSION="v0.42.0" + ARCHIVE="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" + URL="https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${ARCHIVE}" + for i in 1 2 3; do + curl -sSfL "$URL" -o /tmp/jj.tar.gz && break + echo "jj download attempt $i failed, retrying in 5s..." + sleep 5 + [ "$i" = 3 ] && { echo "jj download failed after 3 attempts"; exit 1; } + done + mkdir -p /tmp/jj-extract + tar -xzf /tmp/jj.tar.gz -C /tmp/jj-extract + JJ_BIN="$(find /tmp/jj-extract -type f -name jj | head -n1)" + test -n "$JJ_BIN" || { echo "jj binary not found in archive:"; tar -tzf /tmp/jj.tar.gz; exit 1; } + install -m 0755 "$JJ_BIN" /usr/local/bin/jj + jj --version + - name: Run tests run: make test diff --git a/.gitignore b/.gitignore index bd422ff..9d68954 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ plan-* .pi/ .serena/ + +# jj workspaces +.workspaces/ + +.claude/settings.local.json diff --git a/cmd/arc/paths.go b/cmd/arc/paths.go index e09ab9a..a254182 100644 --- a/cmd/arc/paths.go +++ b/cmd/arc/paths.go @@ -6,13 +6,13 @@ package main import ( "fmt" "os" - "os/exec" "path/filepath" "strings" "text/tabwriter" "github.com/sentiolabs/arc/internal/client" "github.com/sentiolabs/arc/internal/project" + "github.com/sentiolabs/arc/internal/vcs" "github.com/spf13/cobra" ) @@ -305,13 +305,8 @@ func runPathsListCmd(cmd *cobra.Command, args []string) error { return w.Flush() } -// detectGitRemote attempts to get the git remote URL for a directory. -// Returns an empty string if the directory is not a git repo or has no origin. +// detectGitRemote returns the origin remote URL for a directory, supporting +// both git and native jj repositories. Returns "" if none is found. func detectGitRemote(dir string) string { - cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin") - out, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(out)) + return vcs.DetectRemote(dir) } diff --git a/internal/api/resolver.go b/internal/api/resolver.go index f9bf349..8aafbe5 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -3,8 +3,8 @@ package api import ( "context" - "github.com/sentiolabs/arc/internal/gitfs" "github.com/sentiolabs/arc/internal/types" + "github.com/sentiolabs/arc/internal/vcs" ) // resolveProjectForPath is the canonical server-side path-to-project resolver. @@ -12,8 +12,9 @@ import ( // Stages: // 1. Match `path` exactly or against the longest registered ancestor via // store.ResolveProjectByPath (prefix-aware). -// 2. If (1) fails and `path` is inside a linked git worktree, retry (1) -// against the main repository's working directory. +// 2. If (1) fails and `path` is inside a linked git worktree or a secondary +// jj workspace, retry (1) against the main repository's working directory +// (via vcs.DetectMainRepo, which canonicalizes the result). // // Returns the matched workspace, or the underlying not-found error from // the storage layer if no stage succeeds. @@ -23,7 +24,7 @@ func (s *Server) resolveProjectForPath(ctx context.Context, path string) (*types return ws, nil } - mainRepo := gitfs.DetectMainRepo(path) + mainRepo := vcs.DetectMainRepo(path) if mainRepo == "" { return nil, err } diff --git a/internal/jjfs/jjfs.go b/internal/jjfs/jjfs.go new file mode 100644 index 0000000..4bf726a --- /dev/null +++ b/internal/jjfs/jjfs.go @@ -0,0 +1,122 @@ +// Package jjfs provides pure-Go helpers for inspecting on-disk Jujutsu (jj) +// repositories. Like gitfs, it does NOT shell out to the jj binary. It handles +// the narrow problems arc needs: locating .jj entries, mapping a secondary jj +// workspace back to its main repo, and locating the backing git directory. +// +// The on-disk layout assumptions used below — ".jj/repo" as either a directory +// (main workspace) or a file pointer (secondary workspace), and +// ".jj/repo/store/git_target" naming the backing git dir — were validated +// against jj's storage format as of jj 0.42. If a future jj release changes +// this layout, these helpers degrade safely to "" rather than misbehaving. +package jjfs + +import ( + "os" + "path/filepath" + "strings" +) + +// FindJJEntry walks up from dir to locate a .jj entry. Returns the absolute +// path to the .jj directory, or "" if none is found before the filesystem root. +func FindJJEntry(dir string) string { + dir = filepath.Clean(dir) + for { + candidate := filepath.Join(dir, ".jj") + if _, err := os.Lstat(candidate); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// repoDir resolves the actual .jj/repo directory for the given .jj entry. +// Main workspace: .jj/repo is a directory -> (repoPath, false). +// Secondary workspace: .jj/repo is a file holding a path to the shared repo's +// .jj/repo -> (resolved, true). Returns ("", false) if unresolvable. +func repoDir(jjEntry string) (dir string, secondary bool) { + repoPath := filepath.Join(jjEntry, "repo") + info, err := os.Lstat(repoPath) + if err != nil { + return "", false + } + if info.IsDir() { + return repoPath, false + } + data, err := os.ReadFile(repoPath) + if err != nil { + return "", false + } + pointer := strings.TrimSpace(string(data)) + if pointer == "" { + return "", false + } + if !filepath.IsAbs(pointer) { + pointer = filepath.Join(jjEntry, pointer) + } + resolved := filepath.Clean(pointer) + if fi, statErr := os.Stat(resolved); statErr != nil || !fi.IsDir() { + return "", false + } + return resolved, true +} + +// DetectMainRepo returns the main repository's working directory if dir is +// inside a SECONDARY jj workspace. Returns "" if dir is in the main workspace, +// has no reachable .jj entry, or the pointer is malformed. A secondary +// workspace's .jj/repo points to
/.jj/repo; the main working dir is two +// levels up from there. +func DetectMainRepo(dir string) string { + jjEntry := FindJJEntry(dir) + if jjEntry == "" { + return "" + } + repo, secondary := repoDir(jjEntry) + if !secondary { + return "" + } + // Assumes the jj 0.42 layout where repo ==
/.jj/repo, so the main + // working dir is two levels up. The os.Stat guard below only confirms the + // derived path exists, not that it is a valid jj root. + mainJJ := filepath.Dir(repo) //
/.jj + mainWork := filepath.Dir(mainJJ) //
+ if fi, err := os.Stat(mainWork); err != nil || !fi.IsDir() { + return "" + } + return mainWork +} + +// DetectGitBackend returns the absolute path of the backing git directory for +// the jj repo containing dir, resolved via .jj/repo/store/git_target. Returns +// "" if dir is not in a jj repo or the store cannot be resolved. For a +// secondary workspace, the shared repo is followed first. +func DetectGitBackend(dir string) string { + jjEntry := FindJJEntry(dir) + if jjEntry == "" { + return "" + } + repo, _ := repoDir(jjEntry) + if repo == "" { + return "" + } + storeDir := filepath.Join(repo, "store") + data, err := os.ReadFile(filepath.Join(storeDir, "git_target")) + if err != nil { + return "" + } + pointer := strings.TrimSpace(string(data)) + if pointer == "" { + return "" + } + if !filepath.IsAbs(pointer) { + pointer = filepath.Join(storeDir, pointer) + } + backend := filepath.Clean(pointer) + if fi, statErr := os.Stat(backend); statErr != nil || !fi.IsDir() { + return "" + } + return backend +} diff --git a/internal/jjfs/jjfs_test.go b/internal/jjfs/jjfs_test.go new file mode 100644 index 0000000..fa421e8 --- /dev/null +++ b/internal/jjfs/jjfs_test.go @@ -0,0 +1,102 @@ +package jjfs_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/jjfs" +) + +// --- Contract assertions --- +// These verify the design spec. Do NOT modify without updating the approved plan. + +var ( + _ func(string) string = jjfs.FindJJEntry + _ func(string) string = jjfs.DetectMainRepo + _ func(string) string = jjfs.DetectGitBackend +) + +func TestJJFSContract(t *testing.T) { + // Compile-time contract is asserted by the vars above; this test exists so + // the package has a runnable test target. + if jjfs.FindJJEntry("/nonexistent/path/xyz") != "" { + t.Fatal("FindJJEntry on a nonexistent path should return \"\"") + } +} + +// --- Behavior tests (added by the jjfs implementation task) --- + +// writeFile writes content to path, creating parent dirs. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestFindJJEntry_FromSubdir(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".jj", "repo"), 0o755); err != nil { + t.Fatal(err) + } + sub := filepath.Join(root, "a", "b") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + got := jjfs.FindJJEntry(sub) + want := filepath.Join(root, ".jj") + if got != want { + t.Fatalf("FindJJEntry(%q) = %q, want %q", sub, got, want) + } +} + +func TestDetectMainRepo_MainWorkspaceReturnsEmpty(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".jj", "repo"), 0o755); err != nil { + t.Fatal(err) + } + if got := jjfs.DetectMainRepo(root); got != "" { + t.Fatalf("DetectMainRepo(main) = %q, want \"\"", got) + } +} + +func TestDetectMainRepo_SecondaryWorkspace(t *testing.T) { + root := t.TempDir() + // main workspace + if err := os.MkdirAll(filepath.Join(root, "main", ".jj", "repo"), 0o755); err != nil { + t.Fatal(err) + } + // secondary workspace: .jj/repo is a FILE pointing to ../../main/.jj/repo + wsJJ := filepath.Join(root, "ws", ".jj") + writeFile(t, filepath.Join(wsJJ, "repo"), "../../main/.jj/repo") + got := jjfs.DetectMainRepo(filepath.Join(root, "ws")) + want := filepath.Join(root, "main") + if got != want { + t.Fatalf("DetectMainRepo(secondary) = %q, want %q", got, want) + } +} + +func TestDetectGitBackend_NativeRelative(t *testing.T) { + root := t.TempDir() + // native store: git_target points to the internal git dir "git" + store := filepath.Join(root, ".jj", "repo", "store") + if err := os.MkdirAll(filepath.Join(store, "git"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(store, "git_target"), "git") + got := jjfs.DetectGitBackend(root) + want := filepath.Join(store, "git") + if got != want { + t.Fatalf("DetectGitBackend = %q, want %q", got, want) + } +} + +func TestDetectGitBackend_NotJJ(t *testing.T) { + if got := jjfs.DetectGitBackend(t.TempDir()); got != "" { + t.Fatalf("DetectGitBackend(non-jj) = %q, want \"\"", got) + } +} diff --git a/internal/testutil/jjtest/jjtest.go b/internal/testutil/jjtest/jjtest.go new file mode 100644 index 0000000..60d9250 --- /dev/null +++ b/internal/testutil/jjtest/jjtest.go @@ -0,0 +1,72 @@ +// Package jjtest provides helpers for constructing real Jujutsu (jj) +// repositories in tests. Unlike production code, tests may shell out to the +// jj binary. Tests using these helpers skip when jj is not installed. +package jjtest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +const ( + // dirPerm is the directory mode for test scaffolding (matches gittest). + dirPerm = 0o755 + // cfgPerm is the mode for the isolated jj config file. + cfgPerm = 0o600 +) + +// RequireJJ skips the test if the jj binary is not on PATH. +func RequireJJ(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("jj"); err != nil { + t.Skip("jj binary not installed; skipping native-jj test") + } +} + +// isolatedConfig writes a minimal jj config under dir and returns its path, +// so jj never reads or writes the developer's real configuration. +func isolatedConfig(t *testing.T, dir string) string { + t.Helper() + cfg := filepath.Join(dir, "jjconfig.toml") + content := "[user]\nname = \"arc-test\"\nemail = \"arc-test@example.com\"\n" + if err := os.WriteFile(cfg, []byte(content), cfgPerm); err != nil { + t.Fatal(err) + } + return cfg +} + +// Run executes a jj subcommand in workdir, failing the test on error. +func Run(t *testing.T, workdir string, args ...string) { + t.Helper() + cmd := exec.Command("jj", args...) + cmd.Dir = workdir + cmd.Env = append(os.Environ(), "JJ_CONFIG="+isolatedConfig(t, workdir)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("jj %v (in %s): %v\n%s", args, workdir, err, out) + } +} + +// InitNative creates a native (non-colocated) jj repo in dir and returns dir. +func InitNative(t *testing.T, dir string) string { + t.Helper() + if err := os.MkdirAll(dir, dirPerm); err != nil { + t.Fatal(err) + } + Run(t, dir, "git", "init") + return dir +} + +// AddRemote registers a git remote on the jj repo at dir. +func AddRemote(t *testing.T, dir, name, url string) { + t.Helper() + Run(t, dir, "git", "remote", "add", name, url) +} + +// AddWorkspace adds a secondary jj workspace at wsPath from the main repo at +// mainDir, with the given workspace name. +func AddWorkspace(t *testing.T, mainDir, wsPath, name string) { + t.Helper() + Run(t, mainDir, "workspace", "add", "--name", name, wsPath) +} diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..8dd9d54 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,55 @@ +// Package vcs provides version-control detection that works across git and +// Jujutsu (jj) repositories. It dispatches to the pure-Go gitfs and jjfs +// helpers, and shells out to git for remote-URL detection (jj's backend is a +// git repo, so no jj binary is required). +package vcs + +import ( + "os/exec" + "strings" + + "github.com/sentiolabs/arc/internal/core" + "github.com/sentiolabs/arc/internal/gitfs" + "github.com/sentiolabs/arc/internal/jjfs" +) + +// 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 +// core.NormalizePath so it matches registered (canonical) project paths. +func DetectMainRepo(dir string) string { + if main := gitfs.DetectMainRepo(dir); main != "" { + return core.NormalizePath(main) + } + if main := jjfs.DetectMainRepo(dir); main != "" { + return core.NormalizePath(main) + } + return "" +} + +// DetectRemote returns the origin remote URL for the repo containing dir, or +// "". It tries `git -C dir remote get-url origin` first (covers plain git and +// colocated jj). On failure it retries against the jj backing git directory +// (covers native jj). No jj binary is invoked. +func DetectRemote(dir string) string { + if url := gitRemoteURL(dir); url != "" { + return url + } + if backend := jjfs.DetectGitBackend(dir); backend != "" { + if url := gitRemoteURL(backend); url != "" { + return url + } + } + return "" +} + +// gitRemoteURL runs `git -C dir remote get-url origin` and returns the trimmed +// URL, or "" if git fails (not a repo, no origin, or git not installed). +func gitRemoteURL(dir string) string { + cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/vcs/vcs_jj_integration_test.go b/internal/vcs/vcs_jj_integration_test.go new file mode 100644 index 0000000..9332ac7 --- /dev/null +++ b/internal/vcs/vcs_jj_integration_test.go @@ -0,0 +1,58 @@ +package vcs_test + +import ( + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/testutil/jjtest" + "github.com/sentiolabs/arc/internal/vcs" +) + +func TestDetectRemote_NativeJJ(t *testing.T) { + jjtest.RequireJJ(t) + dir := jjtest.InitNative(t, filepath.Join(t.TempDir(), "repo")) + jjtest.AddRemote(t, dir, "origin", "git@example.com:org/native.git") + + if got := vcs.DetectRemote(dir); got != "git@example.com:org/native.git" { + t.Fatalf("DetectRemote(native jj) = %q, want the origin URL", got) + } +} + +func TestDetectMainRepo_NativeJJSecondaryWorkspace(t *testing.T) { + jjtest.RequireJJ(t) + root := t.TempDir() + main := jjtest.InitNative(t, filepath.Join(root, "main")) + ws := filepath.Join(root, "ws") + jjtest.AddWorkspace(t, main, ws, "ws") + + got := vcs.DetectMainRepo(ws) + // vcs canonicalizes via core.NormalizePath; compare against the resolved main. + want := mustEvalSymlinks(t, main) + if got != want { + t.Fatalf("DetectMainRepo(secondary jj workspace) = %q, want %q", got, want) + } +} + +func TestDetectRemote_NativeJJFromSecondaryWorkspace(t *testing.T) { + jjtest.RequireJJ(t) + root := t.TempDir() + main := jjtest.InitNative(t, filepath.Join(root, "main")) + jjtest.AddRemote(t, main, "origin", "git@example.com:org/shared.git") + ws := filepath.Join(root, "ws") + jjtest.AddWorkspace(t, main, ws, "ws") + + // From the secondary workspace, DetectRemote follows .jj/repo to the shared + // repo and reads its git_target backend. + if got := vcs.DetectRemote(ws); got != "git@example.com:org/shared.git" { + t.Fatalf("DetectRemote(secondary workspace) = %q, want shared origin", got) + } +} + +func mustEvalSymlinks(t *testing.T, p string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(p) + if err != nil { + t.Fatalf("EvalSymlinks(%q): %v", p, err) + } + return resolved +} diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go new file mode 100644 index 0000000..9cc5022 --- /dev/null +++ b/internal/vcs/vcs_test.go @@ -0,0 +1,107 @@ +package vcs_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/sentiolabs/arc/internal/vcs" +) + +// --- Contract assertions --- +// 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 TestVCSContract(t *testing.T) { + if vcs.DetectMainRepo("/nonexistent/path/xyz") != "" { + t.Fatal("DetectMainRepo on a nonexistent path should return \"\"") + } +} + +// --- Behavior tests (added by the vcs implementation task) --- + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + full := append([]string{"-C", dir}, args...) + cmd := exec.Command("git", full...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func TestDetectRemote_ColocatedPrefersGit(t *testing.T) { + // A colocated repo has both .git and .jj. DetectRemote must try git first + // and return the .git origin without consulting the jj backend. The jj + // backend is given a DIFFERENT origin so a wrong (jj-first) ordering would + // return that URL instead and fail this test. + dir := t.TempDir() + runGit(t, dir, "init") + runGit(t, dir, "remote", "add", "origin", "git@example.com:org/colocated.git") + + store := filepath.Join(dir, ".jj", "repo", "store") + backend := filepath.Join(store, "git") + if err := os.MkdirAll(backend, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, backend, "init", "--bare") + runGit(t, backend, "remote", "add", "origin", "git@example.com:org/jjbackend.git") + if err := os.WriteFile(filepath.Join(store, "git_target"), []byte("git"), 0o600); err != nil { + t.Fatal(err) + } + + if got := vcs.DetectRemote(dir); got != "git@example.com:org/colocated.git" { + t.Fatalf("DetectRemote(colocated) = %q, want the .git origin (git-first)", got) + } +} + +func TestDetectRemote_PlainGit(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init") + runGit(t, dir, "remote", "add", "origin", "git@example.com:org/repo.git") + if got := vcs.DetectRemote(dir); got != "git@example.com:org/repo.git" { + t.Fatalf("DetectRemote = %q, want the origin URL", got) + } +} + +func TestDetectRemote_NativeJJBackend(t *testing.T) { + // Simulate a native jj repo by hand: .jj/repo/store/git_target -> "git", + // where "git" is a real bare git repo holding the origin remote. + root := t.TempDir() + store := filepath.Join(root, ".jj", "repo", "store") + backend := filepath.Join(store, "git") + if err := os.MkdirAll(store, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, t.TempDir(), "init") // warm-up no-op to fail fast if git missing + if err := os.MkdirAll(backend, 0o755); err != nil { + t.Fatal(err) + } + runGit(t, backend, "init", "--bare") + runGit(t, backend, "remote", "add", "origin", "git@example.com:org/native.git") + if err := os.WriteFile(filepath.Join(store, "git_target"), []byte("git"), 0o600); err != nil { + t.Fatal(err) + } + // No .git at root, so the git-first attempt fails and the jj backend wins. + if got := vcs.DetectRemote(root); got != "git@example.com:org/native.git" { + t.Fatalf("DetectRemote(native jj) = %q, want the backend origin URL", got) + } +} + +func TestDetectRemote_None(t *testing.T) { + if got := vcs.DetectRemote(t.TempDir()); got != "" { + t.Fatalf("DetectRemote(no repo) = %q, want \"\"", got) + } +} + +func TestDetectMainRepo_NotInWorktree(t *testing.T) { + if got := vcs.DetectMainRepo(t.TempDir()); got != "" { + t.Fatalf("DetectMainRepo(plain dir) = %q, want \"\"", got) + } +}