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
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ plan-*

.pi/
.serena/

# jj workspaces
.workspaces/

.claude/settings.local.json
13 changes: 4 additions & 9 deletions cmd/arc/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
9 changes: 5 additions & 4 deletions internal/api/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ 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.
//
// 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.
Expand All @@ -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
}
Expand Down
122 changes: 122 additions & 0 deletions internal/jjfs/jjfs.go
Original file line number Diff line number Diff line change
@@ -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 <main>/.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 == <main>/.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) // <main>/.jj
mainWork := filepath.Dir(mainJJ) // <main>
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
}
102 changes: 102 additions & 0 deletions internal/jjfs/jjfs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
72 changes: 72 additions & 0 deletions internal/testutil/jjtest/jjtest.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading