Skip to content
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
# Minimal Makefile -- DX shortcut for the NOTICE-file generator.
# Add other targets here as the project grows.

.PHONY: notice notice-check
.PHONY: notice notice-check go-build go-test go-vet

# Build the Go binary.
go-build:
go build -o bin/apm-go ./cmd/apm

# Run Go tests.
go-test:
go test ./...

# Run Go vet.
go-vet:
go vet ./...

# Regenerate NOTICE from pyproject.toml + scripts/notice-metadata.yaml.
# Run this whenever you add / remove / bump a runtime dependency.
Expand Down
176 changes: 176 additions & 0 deletions benchmarks/migration-status.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
{
"original_python_lines": 71696,
"migrated_python_lines": 4245,
"migrated_modules": [
{
"module": "src/apm_cli/constants.py",
"go_package": "internal/constants",
"python_lines": 55,
"status": "migrated",
"notes": "Pure constants and enum - no external dependencies"
},
{
"module": "src/apm_cli/version.py",
"go_package": "internal/version",
"python_lines": 101,
"status": "migrated",
"notes": "Version resolution from build constants or pyproject.toml"
},
{
"module": "src/apm_cli/utils/short_sha.py",
"go_package": "internal/utils/sha",
"python_lines": 45,
"status": "migrated",
"notes": "Short SHA formatter with sentinel and hex validation"
},
{
"module": "src/apm_cli/utils/paths.py",
"go_package": "internal/utils/paths",
"python_lines": 27,
"status": "migrated",
"notes": "Cross-platform relative path utility"
},
{
"module": "src/apm_cli/utils/normalization.py",
"go_package": "internal/utils/normalization",
"python_lines": 57,
"status": "migrated",
"notes": "Content normalization: BOM, CRLF, build-ID header stripping"
},
{
"module": "src/apm_cli/utils/yaml_io.py",
"go_package": "internal/utils/yamlio",
"python_lines": 55,
"status": "migrated",
"notes": "YAML I/O with UTF-8; stdlib-only implementation"
},
{
"module": "src/apm_cli/utils/atomic_io.py",
"go_package": "internal/utils/atomicio",
"python_lines": 52,
"status": "migrated",
"notes": "Atomic file write via temp+rename, same-filesystem rename"
},
{
"module": "src/apm_cli/utils/git_env.py",
"go_package": "internal/utils/gitenv",
"python_lines": 97,
"status": "migrated",
"notes": "Cached git lookup and subprocess env sanitization"
},
{
"module": "src/apm_cli/utils/guards.py",
"go_package": "internal/utils/guards",
"python_lines": 123,
"status": "migrated",
"notes": "ReadOnlyProjectGuard with snapshot-based mutation detection"
},
{
"module": "src/apm_cli/utils/subprocess_env.py",
"go_package": "internal/utils/subprocenv",
"python_lines": 84,
"status": "migrated",
"notes": "PyInstaller env restoration; stdlib-only; MapToSlice helper"
},
{
"module": "src/apm_cli/utils/helpers.py",
"go_package": "internal/utils/helpers",
"python_lines": 131,
"status": "migrated",
"notes": "IsToolAvailable, GetAvailablePackageManagers, DetectPlatform, FindPluginJSON"
},
{
"module": "src/apm_cli/utils/content_hash.py",
"go_package": "internal/utils/contenthash",
"python_lines": 108,
"status": "migrated",
"notes": "Deterministic SHA-256 tree hashing; excludes .apm-pin marker and .git/__pycache__"
},
{
"module": "src/apm_cli/utils/exclude.py",
"go_package": "internal/utils/exclude",
"python_lines": 169,
"status": "migrated",
"notes": "Glob pattern matching with ** support; bounded recursion; safety limit on ** count"
},
{
"module": "src/apm_cli/utils/path_security.py",
"go_package": "internal/utils/pathsecurity",
"python_lines": 130,
"status": "migrated",
"notes": "Path traversal guards; iterative percent-decode; EnsurePathWithin; SafeRmtree"
},
{
"module": "src/apm_cli/utils/version_checker.py",
"go_package": "internal/utils/versionchecker",
"python_lines": 193,
"status": "migrated",
"notes": "GitHub API version check; parse_version; is_newer_version; once-per-day cache"
},
{
"module": "src/apm_cli/utils/file_ops.py",
"go_package": "internal/utils/fileops",
"python_lines": 326,
"status": "migrated",
"notes": "Retry-aware rmtree/copytree/copy2; exponential backoff; Windows AV-lock detection"
},
{
"module": "src/apm_cli/utils/console.py",
"go_package": "internal/utils/console",
"python_lines": 224,
"status": "migrated",
"notes": "STATUS_SYMBOLS; RichEcho/Success/Error/Warning/Info; ANSI colour with NO_COLOR guard"
},
{
"module": "src/apm_cli/utils/diagnostics.py",
"go_package": "internal/utils/diagnostics",
"python_lines": 486,
"status": "migrated",
"notes": "DiagnosticCollector; thread-safe; grouped RenderSummary; all category constants"
},
{
"module": "src/apm_cli/utils/install_tui.py",
"go_package": "internal/utils/installtui",
"python_lines": 365,
"status": "migrated",
"notes": "InstallTui; deferred spinner (250ms); ShouldAnimate TTY check; phase/task tracking"
},
{
"module": "src/apm_cli/utils/github_host.py",
"go_package": "internal/utils/githubhost",
"python_lines": 624,
"status": "migrated",
"notes": "Host classification (github/ghes/ghe_com/gitlab/ado/artifactory); GHES precedence; FQDN validation"
},
{
"module": "src/apm_cli/utils/reflink.py",
"go_package": "internal/utils/reflink",
"python_lines": 281,
"status": "migrated",
"notes": "CoW reflink via FICLONE ioctl (Linux); device capability cache; regularCopy fallback"
},
{
"module": "src/apm_cli/install/errors.py",
"go_package": "internal/install/errors",
"python_lines": 113,
"status": "migrated",
"notes": "DirectDependencyError, AuthenticationError, FrozenInstallError, PolicyViolationError"
},
{
"module": "src/apm_cli/install/cache_pin.py",
"go_package": "internal/install/cachepin",
"python_lines": 233,
"status": "migrated",
"notes": "WriteMarker (silent on failures); VerifyMarker (typed CachePinError); schema v1"
},
{
"module": "src/apm_cli/install/context.py",
"go_package": "internal/install/installctx",
"python_lines": 166,
"status": "migrated",
"notes": "InstallContext dataclass -> Go struct; all maps/slices initialised in New()"
}
],
"last_updated": "2026-05-13T00:52:00Z",
"iteration": 13
}
Binary file added bin/apm-go
Binary file not shown.
19 changes: 19 additions & 0 deletions cmd/apm/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Package main is the APM CLI Go entry point.
// This is a stub that will grow as more Python modules are migrated.
package main

import (
"fmt"
"os"

"github.com/githubnext/apm/internal/version"
)

func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Println(version.GetVersion())
return
}
fmt.Fprintln(os.Stderr, "apm-go: stub binary (migration in progress)")
os.Exit(1)
}
44 changes: 44 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Package constants defines shared constants for the APM CLI.
// Migrated from src/apm_cli/constants.py
package constants

// InstallMode controls which dependency types are installed.
type InstallMode string

const (
InstallModeAll InstallMode = "all"
InstallModeAPM InstallMode = "apm"
InstallModeMCP InstallMode = "mcp"
)

// File and directory names.
const (
APMYMLFilename = "apm.yml"
APMLockFilename = "apm.lock"
APMModulesDir = "apm_modules"
APMDir = ".apm"
SkillMDFilename = "SKILL.md"
AgentsMDFilename = "AGENTS.md"
ClaudeMDFilename = "CLAUDE.md"
GitHubDir = ".github"
ClaudeDir = ".claude"
GitignoreFilename = ".gitignore"
APMModulesGitignorePattern = "apm_modules/"
)

// DefaultSkipDirs lists directory names unconditionally skipped during
// primitive-file discovery. These never contain APM primitives and can
// be very large (e.g. node_modules, .git objects).
var DefaultSkipDirs = map[string]struct{}{
".git": {},
"node_modules": {},
"__pycache__": {},
".pytest_cache": {},
".venv": {},
"venv": {},
".tox": {},
"build": {},
"dist": {},
".mypy_cache": {},
"apm_modules": {},
}
98 changes: 98 additions & 0 deletions internal/install/cachepin/cachepin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Package cachepin provides cache-pin marker functionality for drift-replay correctness.
//
// When apm install populates apm_modules/<owner>/<repo>/ from a specific lockfile
// pin, it drops a small JSON marker (.apm-pin) at the package root recording the
// resolved_commit that produced the cache contents.
//
// apm audit drift-replay verifies the marker matches the lockfile's resolved_commit
// BEFORE diffing.
//
// Schema (v1):
//
// {"schema_version": 1, "resolved_commit": "<git-sha-or-similar>"}
package cachepin

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)

// MarkerFilename is the name of the cache-pin marker file.
const MarkerFilename = ".apm-pin"

// SchemaVersion is the current schema version.
const SchemaVersion = 1

// CachePinError is raised when the cache pin is missing, malformed, or stale.
type CachePinError struct {
Msg string
}

func (e *CachePinError) Error() string { return e.Msg }

// IsCachePinError reports whether err is a CachePinError.
func IsCachePinError(err error) bool {
var t *CachePinError
return errors.As(err, &t)
}

type markerPayload struct {
SchemaVersion int `json:"schema_version"`
ResolvedCommit string `json:"resolved_commit"`
}

// WriteMarker writes the cache-pin marker file to installPath.
//
// Idempotent: overwrites any prior marker. Failures are silent because
// they are non-fatal for apm install itself.
func WriteMarker(installPath, resolvedCommit string) {
info, err := os.Stat(installPath)
if err != nil || !info.IsDir() {
return
}
payload := markerPayload{SchemaVersion: SchemaVersion, ResolvedCommit: resolvedCommit}
data, err := json.Marshal(payload)
if err != nil {
return
}
markerPath := filepath.Join(installPath, MarkerFilename)
_ = os.WriteFile(markerPath, data, 0o644)
}

// VerifyMarker verifies the marker at installPath matches expectedCommit.
//
// Returns CachePinError on any of: marker file absent, unreadable, malformed
// JSON, unsupported schema_version, missing resolved_commit field, or commit
// mismatch.
func VerifyMarker(installPath, expectedCommit string) error {
markerPath := filepath.Join(installPath, MarkerFilename)
data, err := os.ReadFile(markerPath)
if err != nil {
if os.IsNotExist(err) {
return &CachePinError{Msg: fmt.Sprintf("cache-pin marker missing at %s (run apm install to refresh)", installPath)}
}
return &CachePinError{Msg: fmt.Sprintf("cannot read cache-pin marker at %s: %v", markerPath, err)}
}

var payload markerPayload
if err := json.Unmarshal(data, &payload); err != nil {
return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s is malformed JSON: %v", markerPath, err)}
}

if payload.SchemaVersion != SchemaVersion {
return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s has unsupported schema_version %d (expected %d)", markerPath, payload.SchemaVersion, SchemaVersion)}
}

if payload.ResolvedCommit == "" {
return &CachePinError{Msg: fmt.Sprintf("cache-pin marker at %s is missing resolved_commit field", markerPath)}
}

if payload.ResolvedCommit != expectedCommit {
return &CachePinError{Msg: fmt.Sprintf("cache-pin marker mismatch at %s: marker=%s expected=%s (run apm install to refresh)", markerPath, payload.ResolvedCommit, expectedCommit)}
}

return nil
}
Loading
Loading