Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ language SDKs live under `sdk/` (e.g. `sdk/typescript/`).
- `app/` — Application service layer implementation
- `ports/` — Port interfaces for Docker and store operations
- `materialize/` — Local sandbox script execution and rsync copying (v0 legacy)
- `eventlog/` — amikalog's hook installer + capture: appends events (one JSON line each) to a per-session JSONL file `<state>/events/{claude,codex}/sessions/{ts}_{session_id}.jsonl`, annotated with git context. `push.go` uploads each changed session file in parallel via an `Uploader`, tracking each file's uploaded byte size in `<state>/events/.amikalog-push-state.json` so only sessions that grew are re-sent (object key = `<repo>/<source>/sessions/<ts>_<session_id>.jsonl`, repo from each session's `git.repo_root`; legacy per-event `event_*.json` files are still uploaded for backward compatibility)
- `eventlog/` — amikalog's hook installer + capture: appends events (one JSON line each) to a per-session JSONL file `<state>/events/{claude,codex}/sessions/{ts}_{session_id}.jsonl`, annotated with git context. `push.go` uploads each changed session file in parallel via an `Uploader`, tracking each file's uploaded byte size in `<state>/events/.amikalog-push-state.json` so only sessions that grew are re-sent (object key = `<repo-path>/sessions/<source>/<ts>_<session_id>.jsonl`, where `<repo-path>` is each session's `git.remote` — the `origin` remote normalized to `host/owner/repo` and nested as folders — falling back to the `git.repo_root` basename then `unknown-repo`; keys are pinned per session on first push so an older layout is never re-keyed; legacy per-event `event_*.json` files are still uploaded for backward compatibility)

### Public Package (`go/pkg/amika/`)
- `service.go` — Public service API used by both the CLI and HTTP server
Expand Down
19 changes: 13 additions & 6 deletions docs/amikalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ records:
| `timestamp` | Capture time (RFC3339 with nanoseconds, UTC) |
| `seq` | The event's position within its session, starting at 0 |
| `cwd` | The working directory the hook reported |
| `git` | Git state of `cwd` (`repo_root`, `commit`, `branch`, `dirty`), or `null` |
| `git` | Git state of `cwd` (`repo_root`, `remote`, `commit`, `branch`, `dirty`), or `null` |
| `payload` | The raw hook payload exactly as the agent provided it |

## Sharing sessions with your org (beta)
Expand All @@ -133,11 +133,18 @@ amikalog beta:fetch <dir> # download the org bucket into <dir>
```

`beta:push` uploads each session file under the object key
`<repo>/<source>/sessions/<ts>_<session-id>.jsonl`, where `<repo>` comes from the
session's `git.repo_root`. Uploads run in parallel. Pushed files are tracked by
size in `<state>/events/.amikalog-push-state.json`, so repeated runs re-upload
only sessions that grew new events since the last push, and re-pushing is
idempotent.
`<repo-path>/sessions/<source>/<ts>_<session-id>.jsonl`. `<repo-path>` is the
repository's `origin` remote normalized to `host/owner/repo` (e.g.
`github.com/acme/api`) and nested as folders, taken from each event's
`git.remote`. Using the full remote path keeps two different checkouts that
share a directory name (two repos both cloned as `api`) from colliding in the
bucket. A repository with no `origin` remote falls back to its directory
basename, and a session with no git context at all to `unknown-repo`. A
session's key is pinned on its first push, so sessions already uploaded under an
older key layout keep it; the new layout applies to sessions pushed from here
on. Uploads run in parallel. Pushed files are tracked by size in
`<state>/events/.amikalog-push-state.json`, so repeated runs re-upload only
sessions that grew new events since the last push, and re-pushing is idempotent.

`beta:fetch` downloads every object in the org bucket into a local directory (in
parallel), recreating the bucket's key tree on disk: your whole org's sessions,
Expand Down
7 changes: 7 additions & 0 deletions go/internal/eventlog/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ import (
type GitInfo struct {
// RepoRoot is the absolute path to the repository's top-level directory.
RepoRoot string `json:"repo_root"`
// Remote is the identity of the repository's "origin" remote, normalized to
// "host/owner/repo" (e.g. "github.com/acme/api") with the scheme, any
// credentials, a port, and a trailing ".git" removed. It is "" when the
// repository has no "origin" remote. It disambiguates repositories that
// share a directory basename (two different checkouts both named "api") when
// sessions are filed in the org storage bucket.
Remote string `json:"remote"`
// Commit is the full SHA of HEAD, or "" in a repository with no commits.
Commit string `json:"commit"`
// Branch is the abbreviated ref name of HEAD, or "HEAD" when detached.
Expand Down
64 changes: 64 additions & 0 deletions go/internal/eventlog/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,78 @@ func GatherGit(dir string) *GitInfo {
commit, _ := gitOutput(dir, "rev-parse", "HEAD")
branch, _ := gitOutput(dir, "rev-parse", "--abbrev-ref", "HEAD")
status, _ := gitOutput(dir, "status", "--porcelain")
// The "origin" remote gives the repository a stable identity independent of
// where it happens to be checked out. A repo with no "origin" (or none at
// all) simply records an empty Remote and is later filed under its basename.
remote, _ := gitOutput(dir, "remote", "get-url", "origin")
return &GitInfo{
RepoRoot: root,
Remote: normalizeRemoteURL(remote),
Commit: commit,
Branch: branch,
Dirty: strings.TrimSpace(status) != "",
}
}

// normalizeRemoteURL reduces a git remote URL to a stable "host/owner/repo"
// identity, dropping the scheme, any credentials, a port, and a trailing
// ".git". It understands the forms git prints for "remote get-url": scheme
// URLs (https://, ssh://, git://) and the scp-like "git@host:owner/repo" form.
// It returns "" for input it cannot resolve to both a host and a path — an
// empty string, or a local "file://"/filesystem remote — so the caller falls
// back to the repository basename.
func normalizeRemoteURL(raw string) string {
s := strings.TrimSpace(raw)
if s == "" {
return ""
}

var host, remotePath string
if i := strings.Index(s, "://"); i >= 0 {
// scheme://[user@]host[:port]/path
rest := s[i+3:]
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return ""
}
host, remotePath = stripUserinfo(rest[:slash]), rest[slash+1:]
host = stripPort(host)
} else {
// scp-like: [user@]host:owner/repo (the colon is the path separator, not
// a port), so a port cannot appear here.
colon := strings.IndexByte(s, ':')
if colon < 0 {
return ""
}
host, remotePath = stripUserinfo(s[:colon]), s[colon+1:]
Comment on lines +69 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat Windows drive remotes as local paths

For a local origin such as C:\src\repo or C:/src/repo on Windows, git remote get-url origin returns a drive-letter path. This scp-like branch treats the drive colon as a host/path separator, so GatherGit records Remote as a C/... path and beta:push files the session under that local path prefix instead of falling back to the repo basename; it can also expose local checkout paths in shared storage. Check for drive-letter/local paths before treating a colon as scp syntax.

Useful? React with 👍 / 👎.

}

remotePath = strings.TrimSuffix(strings.Trim(remotePath, "/"), ".git")
remotePath = strings.Trim(remotePath, "/")
if host == "" || remotePath == "" {
return ""
}
return host + "/" + remotePath
}

// stripUserinfo removes a leading "user@" (or "user:pass@") from a URL
// authority component.
func stripUserinfo(authority string) string {
if at := strings.LastIndexByte(authority, '@'); at >= 0 {
return authority[at+1:]
}
return authority
}

// stripPort removes a trailing ":port" from a host authority, leaving hosts
// without a port untouched.
func stripPort(host string) string {
if i := strings.LastIndexByte(host, ':'); i >= 0 {
return host[:i]
}
return host
}

// gitOutput runs `git -C dir <args...>` and returns its trimmed stdout. The
// boolean reports success; callers treat failure as "information unavailable".
func gitOutput(dir string, args ...string) (string, bool) {
Expand Down
52 changes: 52 additions & 0 deletions go/internal/eventlog/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,58 @@ func TestGatherGit_CommitBranchDirty(t *testing.T) {
}
}

func TestGatherGit_Remote(t *testing.T) {
requireGit(t)
dir := t.TempDir()
initRepo(t, dir)
runGit(t, dir, "remote", "add", "origin", "git@github.com:fixpoint/amika.git")

got := GatherGit(dir)
if got == nil {
t.Fatal("GatherGit(repo) = nil, want info")
}
if got.Remote != "github.com/fixpoint/amika" {
t.Errorf("Remote = %q, want github.com/fixpoint/amika", got.Remote)
}
}

func TestGatherGit_NoRemote(t *testing.T) {
requireGit(t)
dir := t.TempDir()
initRepo(t, dir)
if got := GatherGit(dir); got == nil || got.Remote != "" {
t.Errorf("Remote = %v, want empty for a repo with no origin remote", got)
}
}

func TestNormalizeRemoteURL(t *testing.T) {
cases := []struct {
in string
want string
}{
{"https://github.com/fixpoint/amika.git", "github.com/fixpoint/amika"},
{"https://github.com/fixpoint/amika", "github.com/fixpoint/amika"},
{"https://github.com/fixpoint/amika.git", "github.com/fixpoint/amika"},
{"git@github.com:fixpoint/amika.git", "github.com/fixpoint/amika"},
{"git@github.com:fixpoint/amika", "github.com/fixpoint/amika"},
{"ssh://git@github.com/fixpoint/amika.git", "github.com/fixpoint/amika"},
{"ssh://git@github.com:22/fixpoint/amika.git", "github.com/fixpoint/amika"},
{"git://github.com/fixpoint/amika.git", "github.com/fixpoint/amika"},
{"https://user:token@gitlab.example.com/group/sub/proj.git", "gitlab.example.com/group/sub/proj"},
{"https://github.com/Fixpoint/Amika.git", "github.com/Fixpoint/Amika"}, // case preserved; lowercased at key-build time
{" https://github.com/fixpoint/amika.git ", "github.com/fixpoint/amika"},
{"", ""},
{"file:///home/u/work/amika", ""}, // local remote has no host/owner/repo identity
{"/home/u/work/amika", ""}, // bare local path
{"https://github.com", ""}, // host only, no path
}
for _, c := range cases {
if got := normalizeRemoteURL(c.in); got != c.want {
t.Errorf("normalizeRemoteURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}

func requireGit(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
Expand Down
Loading
Loading