From e3cc36f353d15b628a61c06ebef543265d6bb5cf Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 15:49:37 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(vcs):=20native=20jj=20support=20?= =?UTF-8?q?=E2=80=94=20design=20+=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From d302f436e8edeb4f08c8285765405d71d8e5e21b Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 15:51:36 -0700 Subject: [PATCH 02/14] =?UTF-8?q?feat(vcs):=20native=20jj=20support=20?= =?UTF-8?q?=E2=80=94=20design=20+=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ignore .workspaces/ (jj workspace dogfood dir) on the git side. --- .gitignore | 3 +++ zzz-rootprobe.txt | 1 + 2 files changed, 4 insertions(+) create mode 100644 zzz-rootprobe.txt diff --git a/.gitignore b/.gitignore index bd422ff..59d01f0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ plan-* .pi/ .serena/ + +# jj workspaces +.workspaces/ diff --git a/zzz-rootprobe.txt b/zzz-rootprobe.txt new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/zzz-rootprobe.txt @@ -0,0 +1 @@ +test From 271e1a61e352fb79e8df852c82a35951b69aa5b5 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:27:28 -0700 Subject: [PATCH 03/14] feat(vcs): add jjfs + vcs foundation skeletons and contract tests Creates internal/jjfs and internal/vcs with exact public signatures defined by the design contracts, plus compile-time and runtime contract tests. Bodies are stubs (return ""); implementation lands in later tasks. --- internal/jjfs/jjfs.go | 17 +++++++++++++++++ internal/jjfs/jjfs_test.go | 26 ++++++++++++++++++++++++++ internal/vcs/vcs.go | 12 ++++++++++++ internal/vcs/vcs_test.go | 23 +++++++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 internal/jjfs/jjfs.go create mode 100644 internal/jjfs/jjfs_test.go create mode 100644 internal/vcs/vcs.go create mode 100644 internal/vcs/vcs_test.go diff --git a/internal/jjfs/jjfs.go b/internal/jjfs/jjfs.go new file mode 100644 index 0000000..7f4ca05 --- /dev/null +++ b/internal/jjfs/jjfs.go @@ -0,0 +1,17 @@ +// 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. +package jjfs + +// FindJJEntry walks up from dir to locate a .jj entry (directory). Returns the +// absolute path to the .jj entry, or "" if none is found before the root. +func FindJJEntry(dir string) string { return "" } + +// DetectMainRepo returns the main repository's working directory if dir is +// inside a SECONDARY jj workspace; otherwise "". +func DetectMainRepo(dir string) string { return "" } + +// DetectGitBackend returns the absolute path of the backing git directory for +// the jj repo containing dir (resolved via .jj/repo/store/git_target), or "". +func DetectGitBackend(dir string) string { return "" } diff --git a/internal/jjfs/jjfs_test.go b/internal/jjfs/jjfs_test.go new file mode 100644 index 0000000..ad9b619 --- /dev/null +++ b/internal/jjfs/jjfs_test.go @@ -0,0 +1,26 @@ +package jjfs_test + +import ( + "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) --- diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..425c550 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,12 @@ +// 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 + +// DetectMainRepo returns the canonical main-repo working directory if dir is +// inside a linked git worktree or a secondary jj workspace; otherwise "". +func DetectMainRepo(dir string) string { return "" } + +// DetectRemote returns the origin remote URL for the repo containing dir, or "". +func DetectRemote(dir string) string { return "" } diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go new file mode 100644 index 0000000..b837ca9 --- /dev/null +++ b/internal/vcs/vcs_test.go @@ -0,0 +1,23 @@ +package vcs_test + +import ( + "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) --- From 35e809906dc314cb1b2e11a46da809bda2242c41 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:31:41 -0700 Subject: [PATCH 04/14] feat(jjfs): implement .jj detection, workspace mapping, and git backend lookup Pure-Go implementation of FindJJEntry, DetectMainRepo, and DetectGitBackend with behavior tests covering main workspace, secondary workspace pointer resolution, native relative git_target, and non-jj directories. --- internal/jjfs/jjfs.go | 110 ++++++++++++++++++++++++++++++++++--- internal/jjfs/jjfs_test.go | 76 +++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 7 deletions(-) diff --git a/internal/jjfs/jjfs.go b/internal/jjfs/jjfs.go index 7f4ca05..1298b41 100644 --- a/internal/jjfs/jjfs.go +++ b/internal/jjfs/jjfs.go @@ -4,14 +4,110 @@ // workspace back to its main repo, and locating the backing git directory. package jjfs -// FindJJEntry walks up from dir to locate a .jj entry (directory). Returns the -// absolute path to the .jj entry, or "" if none is found before the root. -func FindJJEntry(dir string) string { return "" } +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; otherwise "". -func DetectMainRepo(dir string) string { return "" } +// 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 "" + } + 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), or "". -func DetectGitBackend(dir string) string { return "" } +// 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 index ad9b619..cdf71f8 100644 --- a/internal/jjfs/jjfs_test.go +++ b/internal/jjfs/jjfs_test.go @@ -1,6 +1,8 @@ package jjfs_test import ( + "os" + "path/filepath" "testing" "github.com/sentiolabs/arc/internal/jjfs" @@ -24,3 +26,77 @@ func TestJJFSContract(t *testing.T) { } // --- 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), 0o644); 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) + } +} From 72b6cef8b202b740d96b3c14ac17accd2c90c412 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:35:07 -0700 Subject: [PATCH 05/14] feat(vcs): implement git/jj dispatcher for main-repo and remote detection Replaces stub bodies with DetectMainRepo (gitfs-first, jjfs fallback, canonicalized via core.NormalizePath) and DetectRemote (git-C first, jjfs backend fallback). Adds four behavior tests using the real git binary. --- internal/vcs/vcs.go | 49 +++++++++++++++++++++++++++++++-- internal/vcs/vcs_test.go | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go index 425c550..8dd9d54 100644 --- a/internal/vcs/vcs.go +++ b/internal/vcs/vcs.go @@ -4,9 +4,52 @@ // 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 "". -func DetectMainRepo(dir string) string { return "" } +// 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 "" +} -// DetectRemote returns the origin remote URL for the repo containing dir, or "". -func DetectRemote(dir string) string { 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_test.go b/internal/vcs/vcs_test.go index b837ca9..085232c 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -1,6 +1,9 @@ package vcs_test import ( + "os" + "os/exec" + "path/filepath" "testing" "github.com/sentiolabs/arc/internal/vcs" @@ -21,3 +24,59 @@ func TestVCSContract(t *testing.T) { } // --- 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_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"), 0o644); 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) + } +} From ffd8e0f4e757928bcfaf6629fff3bfd88349f59a Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:43:23 -0700 Subject: [PATCH 06/14] feat(vcs): route resolver and remote detection through the vcs dispatcher - internal/api/resolver.go: replace gitfs.DetectMainRepo with vcs.DetectMainRepo so the worktree fallback supports both git linked worktrees and native jj workspaces; result is canonicalized via core.NormalizePath. - cmd/arc/paths.go: replace inline git shell-out in detectGitRemote with vcs.DetectRemote so arc init / arc paths add populate GitRemote from native jj repos (via the jj backing git directory). Remove now-unused os/exec import. --- cmd/arc/paths.go | 13 ++++--------- internal/api/resolver.go | 4 ++-- 2 files changed, 6 insertions(+), 11 deletions(-) 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..b273b31 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. @@ -23,7 +23,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 } From 3301072aaddc2ab07fbd91d4edb5c485c73670b7 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:50:25 -0700 Subject: [PATCH 07/14] test(vcs): add jjtest helper and native-jj integration tests Add internal/testutil/jjtest with RequireJJ, Run, InitNative, AddRemote, and AddWorkspace helpers that shell out to the jj binary in isolated temp dirs. Add integration tests in internal/vcs that exercise DetectRemote and DetectMainRepo against a real native jj repo and secondary workspace. --- .gitignore | 2 + internal/testutil/jjtest/jjtest.go | 65 +++++++++++++++++++++++++ internal/vcs/vcs_jj_integration_test.go | 58 ++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 internal/testutil/jjtest/jjtest.go create mode 100644 internal/vcs/vcs_jj_integration_test.go diff --git a/.gitignore b/.gitignore index 59d01f0..9d68954 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ plan-* # jj workspaces .workspaces/ + +.claude/settings.local.json diff --git a/internal/testutil/jjtest/jjtest.go b/internal/testutil/jjtest/jjtest.go new file mode 100644 index 0000000..3e2ce0f --- /dev/null +++ b/internal/testutil/jjtest/jjtest.go @@ -0,0 +1,65 @@ +// 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" +) + +// 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), 0o644); 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, 0o755); 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_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 +} From e1ca29ccf5d93e73f00a91d0dc3dfa9d9f3abb73 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 16:57:05 -0700 Subject: [PATCH 08/14] ci(test): install jj binary so native-jj integration tests run --- .github/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb5310d..af32836 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,15 @@ 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}" + curl -sSfL "$URL" -o /tmp/jj.tar.gz + tar -xzf /tmp/jj.tar.gz -C /usr/local/bin jj + jj --version + - name: Run tests run: make test From 495dc064bbb792f0a6a16660556dd0308d36f50b Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 17:02:23 -0700 Subject: [PATCH 09/14] style(lint): satisfy gosec G306 and mnd in jj test helpers Use 0o600 for test-written files and a named dirPerm/cfgPerm const in jjtest, matching the gittest idiom. No behavior change. --- internal/jjfs/jjfs_test.go | 2 +- internal/testutil/jjtest/jjtest.go | 11 +++++++++-- internal/vcs/vcs_test.go | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/jjfs/jjfs_test.go b/internal/jjfs/jjfs_test.go index cdf71f8..fa421e8 100644 --- a/internal/jjfs/jjfs_test.go +++ b/internal/jjfs/jjfs_test.go @@ -33,7 +33,7 @@ func writeFile(t *testing.T, path, content string) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } } diff --git a/internal/testutil/jjtest/jjtest.go b/internal/testutil/jjtest/jjtest.go index 3e2ce0f..60d9250 100644 --- a/internal/testutil/jjtest/jjtest.go +++ b/internal/testutil/jjtest/jjtest.go @@ -10,6 +10,13 @@ import ( "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() @@ -24,7 +31,7 @@ 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), 0o644); err != nil { + if err := os.WriteFile(cfg, []byte(content), cfgPerm); err != nil { t.Fatal(err) } return cfg @@ -44,7 +51,7 @@ func Run(t *testing.T, workdir string, args ...string) { // 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, 0o755); err != nil { + if err := os.MkdirAll(dir, dirPerm); err != nil { t.Fatal(err) } Run(t, dir, "git", "init") diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go index 085232c..5900687 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -60,7 +60,7 @@ func TestDetectRemote_NativeJJBackend(t *testing.T) { } 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"), 0o644); err != nil { + 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. From dd3af7edc4f89e363b60266a7aa4d0277956c91f Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 17:47:52 -0700 Subject: [PATCH 10/14] ci(test): locate jj binary anywhere in release archive The jj release tarball nests the binary in a versioned directory, so 'tar -C /usr/local/bin jj' failed with 'jj: Not found in archive'. Extract the full archive and find the jj binary instead. --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af32836..3b091d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,11 @@ jobs: ARCHIVE="jj-${JJ_VERSION}-x86_64-unknown-linux-musl.tar.gz" URL="https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${ARCHIVE}" curl -sSfL "$URL" -o /tmp/jj.tar.gz - tar -xzf /tmp/jj.tar.gz -C /usr/local/bin jj + 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 From ff6119bb83e06124a725d787bb070834700c5ad9 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 17:52:43 -0700 Subject: [PATCH 11/14] ci(test): use taiki-e/install-action to install jj Replaces the fragile manual curl/tar download (which broke on the release tarball's nested binary layout) with the maintained taiki-e/install-action, pinned to jj 0.42.0. --- .github/workflows/test.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b091d4..e8bfb50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,17 +25,9 @@ jobs: 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}" - curl -sSfL "$URL" -o /tmp/jj.tar.gz - 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 + uses: taiki-e/install-action@v2 + with: + tool: jj@0.42.0 - name: Run tests run: make test From d4f6b9e5a38ce3d0e370c5b228714e3a3ef95c00 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 17:54:53 -0700 Subject: [PATCH 12/14] ci(test): revert to manual jj download taiki-e/install-action doesn't support jj and its cargo-binstall fallback fails (crate is jj-cli, not binstall-ready). The manual release-tarball download with a find-based binary locate works. --- .github/workflows/test.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8bfb50..3b091d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,9 +25,17 @@ jobs: run: go mod download - name: Install jj - uses: taiki-e/install-action@v2 - with: - tool: jj@0.42.0 + 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}" + curl -sSfL "$URL" -o /tmp/jj.tar.gz + 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 From cf76c08d31f9de9715b84f6134bd39cbdf0cfbed Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 18:06:42 -0700 Subject: [PATCH 13/14] chore: remove stray zzz-rootprobe.txt debug artifact Accidentally committed during a gitignore investigation. --- zzz-rootprobe.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 zzz-rootprobe.txt diff --git a/zzz-rootprobe.txt b/zzz-rootprobe.txt deleted file mode 100644 index 9daeafb..0000000 --- a/zzz-rootprobe.txt +++ /dev/null @@ -1 +0,0 @@ -test From 2d87cd730de3ebd14db23c672bebe492c232f772 Mon Sep 17 00:00:00 2001 From: Ben Firestone Date: Mon, 22 Jun 2026 18:32:52 -0700 Subject: [PATCH 14/14] refactor(vcs): address slop-review findings - resolver.go: update stale doc comment to cover jj secondary workspaces - jjfs.go: document the jj 0.42 on-disk layout assumptions + the DetectMainRepo layout derivation - vcs_test.go: add colocated-jj test proving git-first ordering when both .git and .jj are present - test.yml: add a 3x retry loop to the jj download (matches the Playwright install idiom) so a flaky fetch can't redden the unit gate --- .github/workflows/test.yml | 7 ++++++- internal/api/resolver.go | 5 +++-- internal/jjfs/jjfs.go | 9 +++++++++ internal/vcs/vcs_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b091d4..7d8846e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,12 @@ jobs: 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}" - curl -sSfL "$URL" -o /tmp/jj.tar.gz + 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)" diff --git a/internal/api/resolver.go b/internal/api/resolver.go index b273b31..8aafbe5 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -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. diff --git a/internal/jjfs/jjfs.go b/internal/jjfs/jjfs.go index 1298b41..4bf726a 100644 --- a/internal/jjfs/jjfs.go +++ b/internal/jjfs/jjfs.go @@ -2,6 +2,12 @@ // 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 ( @@ -72,6 +78,9 @@ func DetectMainRepo(dir string) string { 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() { diff --git a/internal/vcs/vcs_test.go b/internal/vcs/vcs_test.go index 5900687..9cc5022 100644 --- a/internal/vcs/vcs_test.go +++ b/internal/vcs/vcs_test.go @@ -36,6 +36,31 @@ func runGit(t *testing.T, dir string, args ...string) { } } +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")