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
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,15 @@ For a deeper technical overview of GoE's architecture and on-chain Git mechanics

## `goe://` Protocol

GoE introduces a custom Git protocol to access on-chain repositories.
GoE repositories are identified on-chain by their contract addresses. The goe:// protocol lets you reference a repository in three ways:

```bash
goe://<repo_address>:<chain_id>
```

- `<repo_address>` — the smart contract address of the repository
- `<chain_id>` — the chain ID where the repository is deployed
| URI Format | Type | Resolution Logic |
|--------------------------------------------|-----------|------------------------------------------------------|
| ```goe://<repo_address>:<chain_id>``` | Canonical | Direct access via on-chain contract address |
| ```goe://<repo_name>:<chain_id>``` | Shorthand | Resolves via the current wallet and repository name |
| ```goe://<owner>/<repo_name>:<chain_id>``` | Full Path | Resolves via any owner’s address and repository name |

> This protocol is automatically handled by the Git Helper installed with `goe-cli`. No additional setup is required.
> **Note:** `<repo_address>` refers to the repository's smart contract; `<chain_id>` is the blockchain network ID.

---

Expand Down Expand Up @@ -104,7 +103,8 @@ goe wallet lock

### 4. Repo Command

Create and manage on-chain repositories and permissions.
> All goe repo commands work with repositories owned by the current wallet. You can refer to a repository either by its
> name or by its contract address.

- **Create a repository**
```bash
Expand All @@ -120,18 +120,18 @@ goe repo list [--chain-id <chain_id>]

- **List branches**
```bash
goe repo branches <repo_address> [--chain-id <chain_id>]
goe repo branches <repo_address|repo_name> [--chain-id <chain_id>]
```

- **Set default branch**
```bash
goe repo default-branch <repo_address> <branch_name> [--chain-id <chain_id>]
goe repo default-branch <repo_address|repo_name> <branch_name> [--chain-id <chain_id>]
```

- **Grant / Revoke push access**
```bash
goe repo grant-push <repo_address> <user_address> [--chain-id <chain_id>]
goe repo revoke-push <repo_address> <user_address> [--chain-id <chain_id>]
goe repo grant-push <repo_address|repo_name> <user_address> [--chain-id <chain_id>]
goe repo revoke-push <repo_address|repo_name> <user_address> [--chain-id <chain_id>]
```

### 5. Example Workflow
Expand Down Expand Up @@ -182,12 +182,12 @@ GOE_GAS_INC_PCT=10 git push -u origin main
#### 5). Set the default branch
```bash
# Only needed if you want to change it later.
goe repo default-branch <repo_address> master --chain-id 11155111
goe repo default-branch <repo_address|repo_name> master --chain-id 11155111
```

#### 6). Grant collaborator push access
```bash
goe repo grant-push <repo_address> <collaborator_address> --chain-id 11155111
goe repo grant-push <repo_address|repo_name> <collaborator_address> --chain-id 11155111
```


Expand Down
115 changes: 54 additions & 61 deletions src/cli/repo/contract.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { ethers, Contract } from "ethers";
import { GOEFactoryAbi, GOERepoAbi, Networks, WalletManager } from "../../core/index.js"
import { ethers } from "ethers";
import {
GOEFactoryAbi,
WalletManager,
validateAddress, randomRPC, getNetworkConfig,
resolveRepoAddress, getHubContract, getRepoContract
} from "../../core/index.js"

export interface RepoInfo {
address: string;
Expand All @@ -12,17 +17,10 @@ export interface RepoInfo {
* Common Helpers
* ============================
*/

function randomRPC(rpcs: string[]): string {
return rpcs[Math.floor(Math.random() * rpcs.length)];
}

function getNetworkConfig(chainId: number) {
const config = Networks[chainId];
if (!config) {
throw new Error(`Unsupported chain ID: ${chainId}.`);
}
return config;
function getOwner(): string {
const address = WalletManager.getDefaultAddress();
if (!address) throw new Error(`Wallet not found. Please run 'goe wallet create' to create it.`);
return address;
}

async function getSigner(chainId: number): Promise<ethers.Signer> {
Expand All @@ -41,18 +39,6 @@ async function waitForTx(tx: ethers.ContractTransactionResponse, action: string)
return receipt;
}

function validateAddress(address: string, label = "address") {
if (!ethers.isAddress(address)) {
throw new Error(`Invalid ${label}: ${address}`);
}
}

async function getRepoContract(repoAddress: string, chainId: number) {
validateAddress(repoAddress, "repoAddress");
const signer = await getSigner(chainId);
return new Contract(repoAddress, GOERepoAbi, signer);
}

/**
* ============================
* Factory Contract Methods
Expand All @@ -61,9 +47,7 @@ async function getRepoContract(repoAddress: string, chainId: number) {
export namespace Factory {
export async function createRepo(repoName: string, chainId: number): Promise<string> {
const signer = await getSigner(chainId);
const { hubAddress } = getNetworkConfig(chainId);

const factory = new Contract(hubAddress, GOEFactoryAbi, signer);
const factory = await getHubContract(chainId, signer);
const tx = await factory.createRepo(ethers.toUtf8Bytes(repoName));
const receipt = await waitForTx(tx, "createRepo");

Expand All @@ -79,17 +63,15 @@ export namespace Factory {
throw new Error("Transaction succeeded but no 'RepoCreated' event found");
}

export async function getUserReposPaginated(
export async function getReposPaginated(
chainId: number,
start = 0,
limit = 50
): Promise<RepoInfo[]> {
const signer = await getSigner(chainId);
const { hubAddress } = getNetworkConfig(chainId);
const factory = await getHubContract(chainId);
const owner = getOwner();

const userAddress = await signer.getAddress();
const factory = new Contract(hubAddress, GOEFactoryAbi, signer);
const repos = await factory.getUserReposPaginated(userAddress, start, limit);
const repos = await factory.getReposPaginated(owner, start, limit);
return repos.map((r: any) => ({
address: r.repoAddress,
name: ethers.toUtf8String(r.repoName),
Expand All @@ -103,14 +85,27 @@ export namespace Factory {
* Repository Contract Methods
* ============================
*/
export namespace Repo {
export async function setDefaultBranch(repoAddress: string, chainId: number, branchName: string) {
const repo = await getRepoContract(repoAddress, chainId);
function normalizeBranchName(branch: string): string {
if (branch.startsWith("refs/")) return branch;

// tags
if (branch.startsWith("tags/")) {
return `refs/${branch}`;
}

// heads
return `refs/heads/${branch}`;
}

export namespace Repo {
export async function setDefaultBranch(repoNamespace: string, chainId: number, branchName: string) {
const signer = await getSigner(chainId);
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId, signer);
const fullName = normalizeBranchName(branchName);

// check
const branches = await Repo.listBranches(repoAddress, chainId);
const branches = await Repo.listBranches(repoAddress, chainId, repo);
const exists = branches.some(b => normalizeBranchName(b.name) === fullName);
if (!exists) {
throw new Error(
Expand All @@ -122,41 +117,50 @@ export namespace Repo {
await waitForTx(tx, "setDefaultBranch");
}

export async function addPusher(repoAddress: string, chainId: number, account: string) {
export async function addPusher(repoNamespace: string, chainId: number, account: string) {
validateAddress(account, "account");
const repo = await getRepoContract(repoAddress, chainId);
const signer = await getSigner(chainId);
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId, signer);
const tx = await repo.addPusher(account);
await waitForTx(tx, "addPusher");
}

export async function removePusher(repoAddress: string, chainId: number, account: string) {
export async function removePusher(repoNamespace: string, chainId: number, account: string) {
validateAddress(account, "account");
const repo = await getRepoContract(repoAddress, chainId);
const signer = await getSigner(chainId);
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId, signer);
const tx = await repo.removePusher(account);
await waitForTx(tx, "removePusher");
}

export async function addMaintainer(repoAddress: string, chainId: number, account: string) {
export async function addMaintainer(repoNamespace: string, chainId: number, account: string) {
validateAddress(account, "account");
const repo = await getRepoContract(repoAddress, chainId);
const signer = await getSigner(chainId);
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId, signer);
const tx = await repo.addMaintainer(account);
await waitForTx(tx, "addMaintainer");
}

export async function canPush(repoAddress: string, chainId: number, account: string) {
export async function canPush(repoNamespace: string, chainId: number, account: string) {
validateAddress(account, "account");
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId);
return repo.canPush(account);
}

export async function canForcePush(repoAddress: string, chainId: number, refName: string, account: string) {
export async function canForcePush(repoNamespace: string, chainId: number, refName: string, account: string) {
validateAddress(account, "account");
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId);
return repo.canForcePush(account, ethers.toUtf8Bytes(refName));
}

export async function listBranches(repoAddress: string, chainId: number, pageSize: number = 50) {
const repo = await getRepoContract(repoAddress, chainId);
export async function listBranches(repoNamespace: string, chainId: number, repoContract?: ethers.Contract) {
const pageSize = 50;
const repo = repoContract ?? await getRepoContract(await resolveRepoAddress(chainId, repoNamespace, getOwner()), chainId);

let start = 0;
const all: { name: string; hash: string }[] = [];
Expand Down Expand Up @@ -185,7 +189,8 @@ export namespace Repo {
return all;
}

export async function getDefaultBranch(repoAddress: string, chainId: number) {
export async function getDefaultBranch(repoNamespace: string, chainId: number) {
const repoAddress = await resolveRepoAddress(chainId, repoNamespace, getOwner());
const repo = await getRepoContract(repoAddress, chainId);
const [refBytes,] = await repo.getDefaultBranch();
if (refBytes.length === 0) {
Expand All @@ -199,15 +204,3 @@ export namespace Repo {
: branch;
}
}

function normalizeBranchName(branch: string): string {
if (branch.startsWith("refs/")) return branch;

// tags
if (branch.startsWith("tags/")) {
return `refs/${branch}`;
}

// heads
return `refs/heads/${branch}`;
}
8 changes: 5 additions & 3 deletions src/cli/repo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ repoCmd
logger.info(`Creating repository "${name}" on chain ${chainId}...`);
const repoAddress = await Factory.createRepo(name, chainId);
logger.success(`Repository created successfully: ${repoAddress}`);
logger.normal(`🔗 Access via: goe://${repoAddress}:${chainId}`);
logger.normal(`🔗 Access via:`);
logger.normal(` goe://${repoAddress}:${chainId} (recommended)`);
logger.normal(` goe://${name}:${chainId} (local alias, current wallet only)`);
} catch (e: any) {
logger.error(`Failed to create repository: ${e.message}`);
}
Expand All @@ -52,12 +54,12 @@ repoCmd
if (chainId === null) return logger.error("Chain ID not specified. Use --chain-id or set GOE_CHAIN_ID environment variable.");

try {
const repos = await Factory.getUserReposPaginated(chainId, cmd.start, cmd.limit);
const repos = await Factory.getReposPaginated(chainId, cmd.start, cmd.limit);
if (repos.length === 0) return logger.info(`No repositories found on chain ${chainId}.`);

logger.success(`Found ${repos.length} repositories:`);
repos.forEach((repo: RepoInfo, idx: number) => {
logger.normal(`${idx + 1}. ${repo.name} (${repo.address}) Created: ${repo.creationTime.toLocaleString()}`);
logger.normal(`${idx + 1}. ${repo.address} (${repo.name}) Created: ${repo.creationTime.toLocaleString()}`);
});
} catch (e: any) {
logger.error(`Failed to fetch repositories: ${e.message}`);
Expand Down
7 changes: 4 additions & 3 deletions src/core/config/abis.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@

export const GOEFactoryAbi = [
"event RepoCreated(address indexed repo, address indexed creator, bytes repoName)",
"event RepoCreated(address indexed repo, address indexed owner, bytes repoName)",
"function createRepo(bytes repoName) external returns (address)",
"function getUserRepoCount(address user) external view returns (uint256)",
"function getUserReposPaginated(address user, uint256 start, uint256 limit) external view returns (tuple(address repoAddress, uint256 creationTime, bytes repoName)[])"
"function getRepoCount(address user) external view returns (uint256)",
"function getReposPaginated(address user, uint256 start, uint256 limit) external view returns (tuple(address repoAddress, uint256 creationTime, bytes repoName)[])",
"function getRepoByName(address owner, bytes calldata repoName) external view returns (address)",
];


Expand Down
2 changes: 1 addition & 1 deletion src/core/config/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ export const Networks: Record<number, any> = {
rbfTimes: 5,
boardcastTimes: 15,
},
hubAddress: "0xA400A766cac75EF3Eb605f23a6a473dB5d4AbBBf",
hubAddress: "0xe0CAb641c88d7E00D4fEfC91aD87657FFd2Af79E",
},
}
17 changes: 17 additions & 0 deletions src/core/contracts/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Contract, ethers } from "ethers";
import { GOEFactoryAbi } from "../config/index.js";
import { getNetworkConfig, randomRPC } from "./utils.js";

export async function getHubContract(
chainId: number,
signer: ethers.Signer | null = null
): Promise<ethers.Contract> {
const network = getNetworkConfig(chainId);
if (signer) {
return new Contract(network.hubAddress, GOEFactoryAbi, signer);
} else {
const rpcUrl = randomRPC(network.rpc);
const provider = new ethers.JsonRpcProvider(rpcUrl);
return new Contract(network.hubAddress, GOEFactoryAbi, provider);
}
}
4 changes: 4 additions & 0 deletions src/core/contracts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './hub.js';
export * from './repo.js';
export * from './resolver/index.js';
export * from './utils.js';
19 changes: 19 additions & 0 deletions src/core/contracts/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Contract, ethers } from "ethers";
import { GOERepoAbi } from "../config/index.js";
import { validateAddress, randomRPC, getNetworkConfig } from "./utils.js";

export async function getRepoContract(
repoAddress: string,
chainId: number,
signer: ethers.Signer | null = null
): Promise<ethers.Contract> {
validateAddress(repoAddress, "repoAddress");
if (signer) {
return new Contract(repoAddress, GOERepoAbi, signer);
} else {
const network = getNetworkConfig(chainId);
const rpcUrl = randomRPC(network.rpc);
const provider = new ethers.JsonRpcProvider(rpcUrl);
return new Contract(repoAddress, GOERepoAbi, provider);
}
}
Loading